미사용 파일정리
This commit is contained in:
@@ -1,95 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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);
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,559 +0,0 @@
|
|||||||
DELETE FROM "tb_genome_cow";
|
|
||||||
/*!40000 ALTER TABLE "tb_genome_cow" DISABLE KEYS */;
|
|
||||||
INSERT INTO "tb_genome_cow" ("reg_dt", "updt_dt", "reg_ip", "reg_user_id", "updt_ip", "updt_user_id", "pk_genome_cow_no", "fk_genome_no", "fk_trait_no", "trait_val", "breed_val", "percentile") VALUES
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 1, 1, 1, 73.20, 2.30, 1),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 2, 1, 2, 76.30, 1.90, 2),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 3, 1, 3, 14.80, 1.90, 2),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 4, 1, 4, -2.20, -1.10, 12),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 5, 1, 5, 0.30, -0.50, 67),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 6, 1, 6, 5.60, 2.00, 2),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 7, 1, 7, 6.70, 2.30, 1),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 8, 1, 8, 9.20, 2.40, 0),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 9, 1, 9, 0.60, 0.30, 39),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 10, 1, 10, 2.60, 2.10, 1),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 11, 1, 11, 2.50, 1.90, 2),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 12, 1, 12, 1.80, 1.40, 8),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 13, 1, 13, 3.80, 2.50, 0),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 14, 1, 14, 2.00, 2.40, 0),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 15, 1, 15, 4.20, 1.00, 16),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 16, 1, 16, 1.50, 2.50, 0),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 17, 1, 17, 6.70, 1.70, 4),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 18, 1, 18, 2.10, 2.40, 0),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 19, 1, 19, 4.70, 2.80, 0),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 20, 1, 20, 6.30, 2.10, 1),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 21, 1, 21, 5.00, 2.10, 1),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 22, 1, 22, 9.60, 2.40, 0),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 23, 1, 23, 3.90, 2.10, 2),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 24, 1, 24, 6.10, 2.10, 1),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 25, 1, 25, 7.20, 1.00, 15),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 26, 1, 26, 0.10, 1.80, 3),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 27, 1, 27, 0.10, -0.10, 54),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 28, 1, 28, 0.20, 1.60, 5),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 29, 1, 29, 0.50, 2.90, 0),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 30, 1, 30, 0.40, 1.70, 4),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 31, 1, 31, 0.30, 1.40, 8),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 32, 1, 32, 0.90, 2.60, 0),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 33, 1, 33, 0.30, 1.80, 3),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 34, 1, 34, 0.20, 1.20, 10),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 35, 1, 35, -0.90, -2.90, 99),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 36, 2, 1, 26.70, 0.10, 46),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 37, 2, 2, 26.00, -0.10, 54),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 38, 2, 3, 4.60, -0.30, 62),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 39, 2, 4, -1.50, -0.60, 27),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 40, 2, 5, 0.50, -0.20, 58),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 41, 2, 6, 1.70, 0.30, 38),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 42, 2, 7, 2.00, 0.40, 34),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 43, 2, 8, 4.50, 0.80, 21),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 44, 2, 9, -0.30, -0.10, 54),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 45, 2, 10, 0.50, 0.20, 42),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 46, 2, 11, 0.50, 0.30, 38),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 47, 2, 12, 0.50, 0.30, 38),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 48, 2, 13, 1.10, 0.60, 27),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 49, 2, 14, 0.90, 0.50, 31),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 50, 2, 15, -0.10, -0.20, 58),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 51, 2, 16, 2.10, -0.20, 58),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 52, 2, 17, 0.50, -0.10, 54),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 53, 2, 18, 1.80, 0.10, 46),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 54, 2, 19, 2.10, 0.10, 46),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 55, 2, 20, 1.70, 0.00, 50),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 56, 2, 21, 2.20, -0.30, 62),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 57, 2, 22, 1.30, 0.10, 46),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 58, 2, 23, 2.30, 0.00, 50),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 59, 2, 24, 3.20, -0.10, 54),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 60, 2, 25, 0.00, 0.00, 50),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 61, 2, 26, 0.00, -0.20, 58),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 62, 2, 27, 0.00, -0.10, 54),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 63, 2, 28, 0.20, 0.10, 46),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 64, 2, 29, 0.20, 0.10, 46),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 65, 2, 30, 0.10, 0.00, 50),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 66, 2, 31, 0.10, 0.00, 50),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 67, 2, 32, 0.10, 0.10, 46),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 68, 2, 33, 0.20, 0.20, 42),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 69, 2, 34, -0.10, -0.10, 54),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 70, 2, 35, 0.20, 0.10, 46),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 71, 3, 1, -18.00, -2.00, 97),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 72, 3, 2, -18.90, -1.90, 97),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 73, 3, 3, -7.20, -2.80, 99),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 74, 3, 4, 0.40, 0.60, 73),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 75, 3, 5, 0.50, -0.10, 54),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 76, 3, 6, -1.60, -1.20, 89),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 77, 3, 7, -1.60, -1.20, 89),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 78, 3, 8, -1.90, -1.50, 93),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 79, 3, 9, -1.40, -1.70, 95),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 80, 3, 10, -1.30, -2.10, 98),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 81, 3, 11, -0.80, -1.60, 94),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 82, 3, 12, -1.10, -2.10, 98),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 83, 3, 13, -0.70, -1.40, 92),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 84, 3, 14, -0.10, -1.00, 83),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 85, 3, 15, -4.70, -2.40, 99),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 86, 3, 16, -0.30, -2.00, 97),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 87, 3, 17, -1.50, -2.00, 97),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 88, 3, 18, -0.60, -2.20, 98),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 89, 3, 19, -0.80, -1.70, 95),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 90, 3, 20, -0.80, -1.50, 93),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 91, 3, 21, -1.50, -1.90, 97),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 92, 3, 22, -2.30, -1.90, 97),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 93, 3, 23, -0.90, -1.80, 96),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 94, 3, 24, -2.50, -2.40, 99),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 95, 3, 25, -1.70, -1.70, 95),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 96, 3, 26, 0.00, -0.80, 78),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 97, 3, 27, -0.10, -0.70, 75),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 98, 3, 28, -0.10, -1.10, 87),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 99, 3, 29, 0.00, -0.50, 70),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 100, 3, 30, 0.10, -0.20, 56),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 101, 3, 31, -0.20, -0.90, 82),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 102, 3, 32, -0.20, -1.30, 89),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 103, 3, 33, -0.10, -1.20, 87),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 104, 3, 34, -0.40, -2.10, 98),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 105, 3, 35, 0.20, 0.90, 19),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 106, 4, 1, 59.70, 1.80, 8),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 107, 4, 2, 16.50, 0.90, 22),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 108, 4, 4, -0.10, -0.30, 64),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 109, 4, 5, 0.40, 0.60, 32),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 110, 4, 6, 0.40, 1.20, 17),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 111, 4, 8, 0.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 112, 4, 15, 0.30, 0.90, 23),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 113, 4, 9, 0.20, 0.60, 32),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 114, 4, 13, 0.20, 0.50, 35),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 115, 4, 14, 0.40, 1.10, 19),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 116, 4, 26, 0.10, 0.40, 44),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 117, 4, 27, 0.20, 0.60, 33),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 118, 4, 28, 0.10, 0.30, 47),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 119, 4, 29, 0.10, 0.20, 52),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 120, 4, 30, 0.00, -0.10, 59),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 121, 4, 31, 0.20, 0.70, 29),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 122, 4, 32, 0.30, 0.80, 25),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 123, 4, 33, 0.10, 0.40, 43),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 124, 4, 34, 0.00, -0.20, 62),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 125, 4, 35, 0.10, 0.30, 48),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 126, 5, 1, 49.20, 1.40, 14),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 127, 5, 2, 9.90, 0.60, 30),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 128, 5, 4, 0.00, -0.10, 55),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 129, 5, 5, 0.20, 0.30, 42),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 130, 5, 6, 0.20, 0.70, 28),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 131, 5, 8, 0.30, 0.80, 25),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 132, 5, 15, 0.20, 0.50, 35),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 133, 5, 9, 0.10, 0.30, 45),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 134, 5, 13, 0.10, 0.20, 48),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 135, 5, 14, 0.20, 0.60, 32),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 136, 5, 26, 0.00, 0.10, 55),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 137, 5, 27, 0.10, 0.30, 47),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 138, 5, 28, 0.00, 0.10, 56),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 139, 5, 29, 0.00, 0.00, 58),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 140, 5, 30, -0.10, -0.20, 64),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 141, 5, 31, 0.10, 0.30, 48),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 142, 5, 32, 0.10, 0.40, 43),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 143, 5, 33, 0.00, 0.10, 54),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 144, 5, 34, -0.10, -0.30, 68),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 145, 5, 35, 0.00, 0.20, 51),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 146, 6, 1, 31.40, 0.90, 32),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 147, 6, 2, 5.70, 0.40, 42),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 148, 6, 4, 0.10, 0.10, 48),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 149, 6, 5, 0.10, 0.10, 52),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 150, 6, 6, 0.10, 0.30, 45),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 151, 6, 8, 0.10, 0.30, 46),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 152, 6, 15, 0.00, 0.10, 54),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 153, 6, 9, 0.00, 0.00, 58),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 154, 6, 13, 0.00, 0.00, 59),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 155, 6, 14, 0.10, 0.20, 49),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 156, 6, 26, 0.00, -0.10, 61),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 157, 6, 27, 0.00, 0.00, 58),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 158, 6, 28, 0.00, -0.10, 62),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 159, 6, 29, 0.00, -0.10, 63),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 160, 6, 30, -0.10, -0.30, 69),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 161, 6, 31, 0.00, 0.10, 57),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 162, 6, 32, 0.00, 0.20, 53),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 163, 6, 33, 0.00, 0.00, 60),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 164, 6, 34, -0.20, -0.50, 74),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 165, 6, 35, 0.00, 0.10, 55),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 166, 7, 1, 29.10, 0.80, 36),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 167, 7, 2, 9.40, 0.50, 32),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 168, 7, 4, 0.00, 0.00, 57),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 169, 7, 5, 0.10, 0.00, 54),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 170, 7, 6, 0.00, 0.10, 52),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 171, 7, 8, 0.00, 0.10, 53),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 172, 7, 15, 0.00, 0.00, 58),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 173, 7, 9, 0.00, -0.10, 62),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 174, 7, 13, 0.00, -0.10, 63),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 175, 7, 14, 0.00, 0.10, 55),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 176, 7, 26, 0.00, -0.20, 65),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 177, 7, 27, 0.00, -0.10, 61),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 178, 7, 28, 0.00, -0.20, 66),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 179, 7, 29, 0.00, -0.20, 67),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 180, 7, 30, -0.10, -0.40, 72),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 181, 7, 31, 0.00, 0.00, 58),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 182, 7, 32, 0.00, 0.10, 56),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 183, 7, 33, 0.00, -0.10, 64),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 184, 7, 34, -0.20, -0.70, 78),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 185, 7, 35, 0.00, 0.00, 59),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 186, 8, 1, 1.10, 0.10, 53),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 187, 8, 2, -2.60, -0.20, 62),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 188, 8, 4, 0.10, 0.20, 45),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 189, 8, 5, 0.00, -0.10, 61),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 190, 8, 6, -0.10, -0.30, 68),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 191, 8, 8, -0.10, -0.30, 69),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 192, 8, 15, -0.10, -0.20, 65),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 193, 8, 9, 0.00, -0.20, 66),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 194, 8, 13, 0.00, -0.20, 68),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 195, 8, 14, -0.10, -0.30, 70),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 196, 8, 26, -0.10, -0.40, 73),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 197, 8, 27, -0.10, -0.50, 76),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 198, 8, 28, -0.10, -0.60, 79),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 199, 8, 29, 0.00, -0.30, 70),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 200, 8, 30, -0.10, -0.50, 77),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 201, 8, 31, -0.10, -0.60, 80),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 202, 8, 32, -0.20, -0.70, 82),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 203, 8, 33, -0.10, -0.60, 81),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 204, 8, 34, -0.30, -1.00, 86),
|
|
||||||
('2025-11-13 11:05:19.605761', '2025-11-13 11:05:19.605761', NULL, NULL, NULL, NULL, 205, 8, 35, 0.00, -0.20, 65),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 206, 4, 1, 50.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 207, 9, 1, 50.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 208, 4, 2, 45.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 209, 9, 2, 45.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 210, 4, 3, 10.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 211, 9, 3, 10.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 212, 4, 4, 2.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 213, 9, 4, 2.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 214, 4, 5, 2.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 215, 9, 5, 2.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 216, 4, 6, 3.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 217, 9, 6, 3.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 218, 4, 7, 3.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 219, 9, 7, 3.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 220, 4, 8, 5.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 221, 9, 8, 5.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 222, 4, 9, 1.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 223, 9, 9, 1.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 224, 4, 10, 2.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 225, 9, 10, 2.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 226, 4, 11, 1.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 227, 9, 11, 1.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 228, 4, 12, 1.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 229, 9, 12, 1.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 230, 4, 13, 2.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 231, 9, 13, 2.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 232, 4, 14, 2.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 233, 9, 14, 2.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 234, 4, 15, 2.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 235, 9, 15, 2.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 236, 4, 16, 5.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 237, 9, 16, 5.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 238, 4, 17, 1.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 239, 9, 17, 1.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 240, 4, 18, 2.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 241, 9, 18, 2.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 242, 4, 19, 2.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 243, 9, 19, 2.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 244, 4, 20, 2.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 245, 9, 20, 2.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 246, 4, 21, 4.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 247, 9, 21, 4.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 248, 4, 22, 1.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 249, 9, 22, 1.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 250, 4, 23, 3.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 251, 9, 23, 3.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 252, 4, 24, 5.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 253, 9, 24, 5.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 254, 4, 25, 1.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 255, 9, 25, 1.00, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 256, 4, 26, 0.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 257, 9, 26, 0.50, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 258, 4, 27, 0.30, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 259, 9, 27, 0.30, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 260, 4, 28, 0.30, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 261, 9, 28, 0.30, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 262, 4, 29, 0.20, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 263, 9, 29, 0.20, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 264, 4, 30, 0.20, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 265, 9, 30, 0.20, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 266, 4, 31, 0.30, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 267, 9, 31, 0.30, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 268, 4, 32, 0.20, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 269, 9, 32, 0.20, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 270, 4, 33, 0.30, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 271, 9, 33, 0.30, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 272, 4, 34, 0.30, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 273, 9, 34, 0.30, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 274, 4, 35, 0.10, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 275, 9, 35, 0.10, 1.50, 15),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 276, 5, 1, 35.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 277, 10, 1, 35.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 278, 5, 2, 38.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 279, 10, 2, 38.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 280, 5, 3, 8.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 281, 10, 3, 8.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 282, 5, 4, 0.50, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 283, 10, 4, 0.50, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 284, 5, 5, 1.50, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 285, 10, 5, 1.50, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 286, 5, 6, 1.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 287, 10, 6, 1.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 288, 5, 7, 1.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 289, 10, 7, 1.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 290, 5, 8, 3.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 291, 10, 8, 3.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 292, 5, 9, 0.50, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 293, 10, 9, 0.50, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 294, 5, 10, 1.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 295, 10, 10, 1.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 296, 5, 11, 0.80, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 297, 10, 11, 0.80, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 298, 5, 12, 0.80, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 299, 10, 12, 0.80, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 300, 5, 13, 1.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 301, 10, 13, 1.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 302, 5, 14, 1.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 303, 10, 14, 1.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 304, 5, 15, 1.50, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 305, 10, 15, 1.50, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 306, 5, 16, 4.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 307, 10, 16, 4.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 308, 5, 17, 1.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 309, 10, 17, 1.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 310, 5, 18, 2.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 311, 10, 18, 2.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 312, 5, 19, 2.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 313, 10, 19, 2.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 314, 5, 20, 1.80, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 315, 10, 20, 1.80, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 316, 5, 21, 3.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 317, 10, 21, 3.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 318, 5, 22, 1.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 319, 10, 22, 1.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 320, 5, 23, 2.50, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 321, 10, 23, 2.50, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 322, 5, 24, 3.80, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 323, 10, 24, 3.80, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 324, 5, 25, 0.50, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 325, 10, 25, 0.50, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 326, 5, 26, 0.40, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 327, 10, 26, 0.40, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 328, 5, 27, 0.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 329, 10, 27, 0.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 330, 5, 28, 0.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 331, 10, 28, 0.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 332, 5, 29, 0.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 333, 10, 29, 0.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 334, 5, 30, 0.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 335, 10, 30, 0.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 336, 5, 31, 0.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 337, 10, 31, 0.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 338, 5, 32, 0.10, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 339, 10, 32, 0.10, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 340, 5, 33, 0.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 341, 10, 33, 0.20, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 342, 5, 34, 0.30, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 343, 10, 34, 0.30, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 344, 5, 35, 0.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 345, 10, 35, 0.00, 0.90, 25),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 346, 6, 1, 42.00, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 347, 11, 1, 42.00, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 348, 6, 2, 43.00, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 349, 11, 2, 43.00, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 350, 6, 3, 8.50, 0.50, 38),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 351, 11, 3, 8.50, 0.50, 38),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 352, 6, 4, -0.80, -0.20, 48),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 353, 11, 4, -0.80, -0.20, 48),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 354, 6, 5, 1.80, 0.90, 32),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 355, 11, 5, 1.80, 0.90, 32),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 356, 6, 6, 1.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 357, 11, 6, 1.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 358, 6, 7, 1.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 359, 11, 7, 1.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 360, 6, 8, 3.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 361, 11, 8, 3.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 362, 6, 9, 0.60, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 363, 11, 9, 0.60, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 364, 6, 10, 1.00, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 365, 11, 10, 1.00, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 366, 6, 11, 0.70, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 367, 11, 11, 0.70, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 368, 6, 12, 0.70, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 369, 11, 12, 0.70, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 370, 6, 13, 1.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 371, 11, 13, 1.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 372, 6, 14, 1.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 373, 11, 14, 1.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 374, 6, 15, 1.40, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 375, 11, 15, 1.40, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 376, 6, 16, 3.80, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 377, 11, 16, 3.80, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 378, 6, 17, 1.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 379, 11, 17, 1.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 380, 6, 18, 2.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 381, 11, 18, 2.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 382, 6, 19, 2.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 383, 11, 19, 2.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 384, 6, 20, 1.70, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 385, 11, 20, 1.70, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 386, 6, 21, 3.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 387, 11, 21, 3.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 388, 6, 22, 1.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 389, 11, 22, 1.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 390, 6, 23, 2.60, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 391, 11, 23, 2.60, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 392, 6, 24, 3.90, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 393, 11, 24, 3.90, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 394, 6, 25, 0.60, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 395, 11, 25, 0.60, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 396, 6, 26, 0.30, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 397, 11, 26, 0.30, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 398, 6, 27, 0.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 399, 11, 27, 0.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 400, 6, 28, 0.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 401, 11, 28, 0.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 402, 6, 29, 0.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 403, 11, 29, 0.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 404, 6, 30, 0.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 405, 11, 30, 0.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 406, 6, 31, 0.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 407, 11, 31, 0.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 408, 6, 32, 0.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 409, 11, 32, 0.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 410, 6, 33, 0.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 411, 11, 33, 0.20, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 412, 6, 34, 0.30, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 413, 11, 34, 0.30, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 414, 6, 35, 0.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 415, 11, 35, 0.10, 0.60, 35),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 416, 7, 1, 5.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 417, 12, 1, 5.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 418, 7, 2, 10.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 419, 12, 2, 10.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 420, 7, 3, 2.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 421, 12, 3, 2.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 422, 7, 4, -2.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 423, 12, 4, -2.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 424, 7, 5, -1.20, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 425, 12, 5, -1.20, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 426, 7, 6, -2.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 427, 12, 6, -2.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 428, 7, 7, -2.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 429, 12, 7, -2.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 430, 7, 8, -1.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 431, 12, 8, -1.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 432, 7, 9, -1.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 433, 12, 9, -1.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 434, 7, 10, -1.20, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 435, 12, 10, -1.20, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 436, 7, 11, -1.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 437, 12, 11, -1.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 438, 7, 12, -1.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 439, 12, 12, -1.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 440, 7, 13, -0.80, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 441, 12, 13, -0.80, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 442, 7, 14, -0.80, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 443, 12, 14, -0.80, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 444, 7, 15, -1.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 445, 12, 15, -1.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 446, 7, 16, 1.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 447, 12, 16, 1.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 448, 7, 17, 0.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 449, 12, 17, 0.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 450, 7, 18, 0.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 451, 12, 18, 0.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 452, 7, 19, 0.80, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 453, 12, 19, 0.80, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 454, 7, 20, 0.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 455, 12, 20, 0.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 456, 7, 21, 1.20, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 457, 12, 21, 1.20, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 458, 7, 22, 0.20, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 459, 12, 22, 0.20, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 460, 7, 23, 1.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 461, 12, 23, 1.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 462, 7, 24, 1.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 463, 12, 24, 1.50, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 464, 7, 25, -0.80, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 465, 12, 25, -0.80, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 466, 7, 26, -0.20, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 467, 12, 26, -0.20, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 468, 7, 27, -0.20, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 469, 12, 27, -0.20, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 470, 7, 28, 0.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 471, 12, 28, 0.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 472, 7, 29, 0.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 473, 12, 29, 0.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 474, 7, 30, -0.10, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 475, 12, 30, -0.10, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 476, 7, 31, -0.10, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 477, 12, 31, -0.10, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 478, 7, 32, -0.10, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 479, 12, 32, -0.10, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 480, 7, 33, 0.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 481, 12, 33, 0.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 482, 7, 34, 0.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 483, 12, 34, 0.00, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 484, 7, 35, -0.20, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 485, 12, 35, -0.20, -1.50, 85),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 486, 8, 1, 45.00, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 487, 13, 1, 45.00, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 488, 8, 2, 42.00, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 489, 13, 2, 42.00, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 490, 8, 3, 9.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 491, 13, 3, 9.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 492, 8, 4, 1.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 493, 13, 4, 1.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 494, 8, 5, 2.00, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 495, 13, 5, 2.00, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 496, 8, 6, 2.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 497, 13, 6, 2.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 498, 8, 7, 2.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 499, 13, 7, 2.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 500, 8, 8, 4.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 501, 13, 8, 4.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 502, 8, 9, 1.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 503, 13, 9, 1.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 504, 8, 10, 1.80, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 505, 13, 10, 1.80, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 506, 8, 11, 1.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 507, 13, 11, 1.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 508, 8, 12, 1.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 509, 13, 12, 1.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 510, 8, 13, 1.80, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 511, 13, 13, 1.80, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 512, 8, 14, 1.80, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 513, 13, 14, 1.80, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 514, 8, 15, 2.00, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 515, 13, 15, 2.00, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 516, 8, 16, 4.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 517, 13, 16, 4.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 518, 8, 17, 1.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 519, 13, 17, 1.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 520, 8, 18, 2.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 521, 13, 18, 2.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 522, 8, 19, 2.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 523, 13, 19, 2.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 524, 8, 20, 1.90, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 525, 13, 20, 1.90, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 526, 8, 21, 3.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 527, 13, 21, 3.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 528, 8, 22, 1.30, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 529, 13, 22, 1.30, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 530, 8, 23, 2.80, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 531, 13, 23, 2.80, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 532, 8, 24, 4.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 533, 13, 24, 4.50, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 534, 8, 25, 0.80, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 535, 13, 25, 0.80, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 536, 8, 26, 0.40, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 537, 13, 26, 0.40, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 538, 8, 27, 0.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 539, 13, 27, 0.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 540, 8, 28, 0.30, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 541, 13, 28, 0.30, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 542, 8, 29, 0.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 543, 13, 29, 0.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 544, 8, 30, 0.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 545, 13, 30, 0.20, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 546, 8, 31, 0.30, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 547, 13, 31, 0.30, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 548, 8, 32, 0.10, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 549, 13, 32, 0.10, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 550, 8, 33, 0.30, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 551, 13, 33, 0.30, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 552, 8, 34, 0.30, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 553, 13, 34, 0.30, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 554, 8, 35, 0.10, 1.32, 18),
|
|
||||||
('2025-11-13 11:05:20.723791', '2025-11-13 11:05:20.723791', NULL, NULL, NULL, NULL, 555, 13, 35, 0.10, 1.32, 18);
|
|
||||||
/*!40000 ALTER TABLE "tb_genome_cow" ENABLE KEYS */;
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,214 +0,0 @@
|
|||||||
-- ============================================
|
|
||||||
-- 간단 Seed 데이터 v3 (업로드 테스트용)
|
|
||||||
-- 시나리오: 마스터 데이터 + 최소 사용자만 / 실제 업로드 파일로 테스트
|
|
||||||
-- ============================================
|
|
||||||
-- 설명: 파일 업로드 기능 테스트를 위한 최소 seed 데이터
|
|
||||||
-- - 마스터 데이터 (마커, SNP, 형질 등) 포함
|
|
||||||
-- - 사용자/농장 데이터만 최소로 포함
|
|
||||||
-- - 개체 데이터는 업로드 파일로 생성 예정
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- PART 0: 스키마 초기화 (선택사항 - 완전히 새로 시작할 때만 사용)
|
|
||||||
-- ============================================
|
|
||||||
-- 주의: 아래 주석을 해제하면 모든 테이블과 데이터가 삭제됩니다
|
|
||||||
-- DROP SCHEMA public CASCADE;
|
|
||||||
-- CREATE SCHEMA public;
|
|
||||||
-- GRANT ALL ON SCHEMA public TO postgres;
|
|
||||||
-- GRANT ALL ON SCHEMA public TO public;
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- PART 0: ENUM 타입 생성 및 VARCHAR 길이 수정
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- ENUM 타입이 이미 존재하면 삭제 후 재생성
|
|
||||||
DROP TYPE IF EXISTS tb_cow_anlys_stat_enum CASCADE;
|
|
||||||
CREATE TYPE tb_cow_anlys_stat_enum AS ENUM ('친자일치', '친자불일치', '분석불가', '이력제부재');
|
|
||||||
|
|
||||||
DROP TYPE IF EXISTS tb_cow_cow_repro_type_enum CASCADE;
|
|
||||||
CREATE TYPE tb_cow_cow_repro_type_enum AS ENUM ('공란우', '수란우', '인공수정', '도태대상');
|
|
||||||
|
|
||||||
DROP TYPE IF EXISTS tb_cow_cow_status_enum CASCADE;
|
|
||||||
CREATE TYPE tb_cow_cow_status_enum AS ENUM ('정상', '폐사', '도축', '매각');
|
|
||||||
|
|
||||||
-- tb_cow 테이블에 컬럼 추가 (이미 있으면 무시)
|
|
||||||
ALTER TABLE tb_cow ADD COLUMN IF NOT EXISTS anlys_stat tb_cow_anlys_stat_enum;
|
|
||||||
ALTER TABLE tb_cow ADD COLUMN IF NOT EXISTS cow_repro_type tb_cow_cow_repro_type_enum;
|
|
||||||
ALTER TABLE tb_cow ADD COLUMN IF NOT EXISTS cow_status tb_cow_cow_status_enum DEFAULT '정상'::tb_cow_cow_status_enum;
|
|
||||||
ALTER TABLE tb_cow ADD COLUMN IF NOT EXISTS cow_short_no varchar(4);
|
|
||||||
|
|
||||||
-- 기타 테이블 컬럼 확장 (테이블 있을 경우만)
|
|
||||||
ALTER TABLE tb_genome_trait ALTER COLUMN dam TYPE varchar(20);
|
|
||||||
ALTER TABLE tb_region_genome ALTER COLUMN dam TYPE varchar(20);
|
|
||||||
|
|
||||||
-- tb_pedigree 모든 ID 컬럼 확장
|
|
||||||
ALTER TABLE tb_pedigree
|
|
||||||
ALTER COLUMN fk_animal_no TYPE varchar(20),
|
|
||||||
ALTER COLUMN sire_id TYPE varchar(20),
|
|
||||||
ALTER COLUMN dam_id TYPE varchar(20),
|
|
||||||
ALTER COLUMN paternal_grandsire_id TYPE varchar(20),
|
|
||||||
ALTER COLUMN paternal_granddam_id TYPE varchar(20),
|
|
||||||
ALTER COLUMN maternal_grandsire_id TYPE varchar(20),
|
|
||||||
ALTER COLUMN maternal_granddam_id TYPE varchar(20);
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- PART 1: 마스터 데이터 (필수 참조 데이터)
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- 1. 마커 타입 (2개)
|
|
||||||
INSERT INTO tb_marker_type (pk_type_cd, type_nm, type_desc, use_yn, reg_dt, updt_dt)
|
|
||||||
VALUES
|
|
||||||
('QTY', '육량형', '육량 관련 마커 (도체중, 등심단면적 등)', 'Y', NOW(), NOW()),
|
|
||||||
('QLT', '육질형', '육질 관련 마커 (근내지방도, 연도 등)', 'Y', NOW(), NOW())
|
|
||||||
ON CONFLICT (pk_type_cd) DO NOTHING;
|
|
||||||
|
|
||||||
-- 2. 마커 정보 (7개 유전자만 - 각 1개 대표 SNP)
|
|
||||||
INSERT INTO tb_marker (fk_marker_type, marker_nm, marker_desc, related_trait, snp_cnt, use_yn, favorable_allele, reg_dt, updt_dt)
|
|
||||||
VALUES
|
|
||||||
-- 육량형 (3개)
|
|
||||||
('QTY', 'PLAG1', 'Pleiomorphic adenoma gene 1', '도체중', 1, 'Y', 'G', NOW(), NOW()),
|
|
||||||
('QTY', 'NCAPG2', 'Non-SMC condensin II complex subunit G2', '체구', 1, 'Y', 'T', NOW(), NOW()),
|
|
||||||
('QTY', 'BTB', 'BTB domain containing', '등심단면적', 1, 'Y', 'T', NOW(), NOW()),
|
|
||||||
|
|
||||||
-- 육질형 (4개)
|
|
||||||
('QLT', 'NT5E', '5 prime nucleotidase ecto', '근내지방도', 1, 'Y', 'C', NOW(), NOW()),
|
|
||||||
('QLT', 'SCD', 'Stearoyl-CoA desaturase', '지방산불포화도', 1, 'Y', 'G', NOW(), NOW()),
|
|
||||||
('QLT', 'FASN', 'Fatty acid synthase', '지방합성', 1, 'Y', 'G', NOW(), NOW()),
|
|
||||||
('QLT', 'CAPN1', 'Calpain 1', '연도', 1, 'Y', 'G', NOW(), NOW())
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- 3. SNP 정보 (7개 - 각 유전자당 1개 대표 SNP)
|
|
||||||
INSERT INTO tb_snp_info (snp_nm, chr, position, snp_alleles, reg_dt, updt_dt)
|
|
||||||
VALUES
|
|
||||||
-- 육량형 대표 SNP
|
|
||||||
('14:25016263', '14', 25016263, '[G/C]', NOW(), NOW()), -- PLAG1 (GG/GC/CC)
|
|
||||||
('7:38528304', '7', 38528304, '[T/G]', NOW(), NOW()), -- NCAPG2 (TT/TG/GG)
|
|
||||||
('5:45120340', '5', 45120340, '[T/C]', NOW(), NOW()), -- BTB (TT/TC/CC)
|
|
||||||
|
|
||||||
-- 육질형 대표 SNP
|
|
||||||
('6:58230560', '6', 58230560, '[C/T]', NOW(), NOW()), -- NT5E (CC/CT/TT)
|
|
||||||
('26:21194784', '26', 21194784, '[G/A]', NOW(), NOW()), -- SCD (GG/GA/AA)
|
|
||||||
('19:51230120', '19', 51230120, '[G/A]', NOW(), NOW()), -- FASN (GG/GA/AA)
|
|
||||||
('29:44104889', '29', 44104889, '[G/A]', NOW(), NOW()) -- CAPN1 (GG/GA/AA)
|
|
||||||
ON CONFLICT (snp_nm) DO NOTHING;
|
|
||||||
|
|
||||||
-- 4. 마커-SNP 매핑 (7개 - 각 유전자당 1개 대표 SNP)
|
|
||||||
INSERT INTO tb_marker_snp (pk_fk_marker_no, pk_fk_snp_no, reg_dt, updt_dt)
|
|
||||||
SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'PLAG1' AND s.snp_nm = '14:25016263'
|
|
||||||
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'NCAPG2' AND s.snp_nm = '7:38528304'
|
|
||||||
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'BTB' AND s.snp_nm = '5:45120340'
|
|
||||||
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'NT5E' AND s.snp_nm = '6:58230560'
|
|
||||||
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'SCD' AND s.snp_nm = '26:21194784'
|
|
||||||
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'FASN' AND s.snp_nm = '19:51230120'
|
|
||||||
UNION ALL SELECT m.pk_marker_no, s.pk_snp_no, NOW(), NOW() FROM tb_marker m, tb_snp_info s WHERE m.marker_nm = 'CAPN1' AND s.snp_nm = '29:44104889'
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- 5. 형질 정보 (35개 경제형질)
|
|
||||||
INSERT INTO tb_genome_trait_info (trait_nm, trait_ctgry, trait_desc, use_yn, reg_dt, updt_dt)
|
|
||||||
VALUES
|
|
||||||
-- 성장형질 (6개)
|
|
||||||
('이유체중', '성장', '송아지 이유시 체중 (kg)', 'Y', NOW(), NOW()),
|
|
||||||
('육성체중', '성장', '육성기 체중 (kg)', 'Y', NOW(), NOW()),
|
|
||||||
('12개월체중', '성장', '12개월령 체중 (kg)', 'Y', NOW(), NOW()),
|
|
||||||
('일당증체량', '성장', '일일 체중 증가량 (kg/day)', 'Y', NOW(), NOW()),
|
|
||||||
('체고', '성장', '어깨높이 (cm)', 'Y', NOW(), NOW()),
|
|
||||||
('체장', '성장', '몸통 길이 (cm)', 'Y', NOW(), NOW()),
|
|
||||||
|
|
||||||
-- 도체형질 (10개)
|
|
||||||
('도체중', '도체', '도축 후 도체 무게 (kg)', 'Y', NOW(), NOW()),
|
|
||||||
('등지방두께', '도체', '등 부위 지방 두께 (mm)', 'Y', NOW(), NOW()),
|
|
||||||
('등심단면적', '도체', '등심 단면적 (cm²)', 'Y', NOW(), NOW()),
|
|
||||||
('근내지방도', '도체', '마블링 점수 (1~9)', 'Y', NOW(), NOW()),
|
|
||||||
('육량지수', '도체', '고기 생산량 지수', 'Y', NOW(), NOW()),
|
|
||||||
('육색', '도체', '고기 색깔 (1~9)', 'Y', NOW(), NOW()),
|
|
||||||
('지방색', '도체', '지방 색깔 (1~9)', 'Y', NOW(), NOW()),
|
|
||||||
('조직감', '도체', '고기 조직감 (1~3)', 'Y', NOW(), NOW()),
|
|
||||||
('성숙도', '도체', '고기 성숙 정도 (1~9)', 'Y', NOW(), NOW()),
|
|
||||||
('보수력', '도체', '수분 보유 능력 (%)', 'Y', NOW(), NOW()),
|
|
||||||
|
|
||||||
-- 육질형질 (7개)
|
|
||||||
('연도', '육질', '고기 부드러운 정도', 'Y', NOW(), NOW()),
|
|
||||||
('다즙성', '육질', '육즙 함량', 'Y', NOW(), NOW()),
|
|
||||||
('풍미', '육질', '고기 맛', 'Y', NOW(), NOW()),
|
|
||||||
('가열감량', '육질', '조리시 손실율 (%)', 'Y', NOW(), NOW()),
|
|
||||||
('전단력', '육질', '자르는 힘 (kgf)', 'Y', NOW(), NOW()),
|
|
||||||
('지방산불포화도', '육질', '불포화 지방산 비율 (%)', 'Y', NOW(), NOW()),
|
|
||||||
('오메가3비율', '육질', '오메가3 지방산 비율 (%)', 'Y', NOW(), NOW()),
|
|
||||||
|
|
||||||
-- 번식형질 (6개)
|
|
||||||
('초산월령', '번식', '첫 분만 월령 (개월)', 'Y', NOW(), NOW()),
|
|
||||||
('분만간격', '번식', '분만 사이 기간 (일)', 'Y', NOW(), NOW()),
|
|
||||||
('수태율', '번식', '임신 성공률 (%)', 'Y', NOW(), NOW()),
|
|
||||||
('분만난이도', '번식', '분만 어려움 정도 (1~5)', 'Y', NOW(), NOW()),
|
|
||||||
('송아지생존율', '번식', '신생 송아지 생존율 (%)', 'Y', NOW(), NOW()),
|
|
||||||
('모성능력', '번식', '어미소 양육 능력', 'Y', NOW(), NOW()),
|
|
||||||
|
|
||||||
-- 건강형질 (6개)
|
|
||||||
('체세포수', '건강', '우유 체세포 수 (천개/ml)', 'Y', NOW(), NOW()),
|
|
||||||
('질병저항성', '건강', '질병 저항 능력', 'Y', NOW(), NOW()),
|
|
||||||
('발굽건강', '건강', '발굽 건강 상태', 'Y', NOW(), NOW()),
|
|
||||||
('유방염저항성', '건강', '유방염 저항성', 'Y', NOW(), NOW()),
|
|
||||||
('호흡기질환저항성', '건강', '호흡기 질환 저항성', 'Y', NOW(), NOW()),
|
|
||||||
('대사질환저항성', '건강', '대사 질환 저항성', 'Y', NOW(), NOW())
|
|
||||||
ON CONFLICT (trait_nm) DO NOTHING;
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- PART 2: 사용자 및 농장 데이터 (최소)
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- 사용자 2명 (ADMIN + TEST 농장주)
|
|
||||||
INSERT INTO tb_user (user_id, user_password, user_nm, user_role, hp_no, email, addr, reg_dt, updt_dt)
|
|
||||||
VALUES
|
|
||||||
('admin', '$2b$10$abcdefghijklmnopqrstuvwxyz123456789', '관리자', 'ADMIN', '010-0000-0000', 'admin@test.com', '서울시', NOW(), NOW()),
|
|
||||||
('testuser', '$2b$10$abcdefghijklmnopqrstuvwxyz123456789', '테스트농장주', 'FARM_OWNER', '010-1111-1111', 'test@test.com', '충북 청주시', NOW(), NOW())
|
|
||||||
ON CONFLICT (user_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- 농장 1개 (테스트용)
|
|
||||||
INSERT INTO tb_farm (farm_nm, farm_addr, owner_nm, contact, reg_dt, updt_dt)
|
|
||||||
VALUES
|
|
||||||
('테스트농장', '충북 청주시 상당구', '테스트농장주', '010-1111-1111', NOW(), NOW())
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- PART 3: KPN (종축 수소) 데이터 (최소)
|
|
||||||
-- ============================================
|
|
||||||
|
|
||||||
-- KPN 수소 2마리만 (부계/모계 참조용)
|
|
||||||
INSERT INTO tb_kpn (kpn_no, kpn_nm, birth_dt, sire, dam, reg_dt, updt_dt)
|
|
||||||
VALUES
|
|
||||||
('KPN001001001001', '종축수소1', '2018-01-15', NULL, NULL, NOW(), NOW()),
|
|
||||||
('KPN001001001002', '종축수소2', '2019-03-20', NULL, NULL, NOW(), NOW())
|
|
||||||
ON CONFLICT (kpn_no) DO NOTHING;
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- 업로드 테스트용 안내
|
|
||||||
-- ============================================
|
|
||||||
-- 다음 단계: 파일 업로드로 데이터 생성
|
|
||||||
--
|
|
||||||
-- 1. 개체정보 파일 업로드 (fileType: "유전자")
|
|
||||||
-- → 농장주명 + 개체번호 매핑 정보
|
|
||||||
-- → tb_cow 테이블에 저장
|
|
||||||
--
|
|
||||||
-- 2. 유전능력평가 결과 업로드 (fileType: "유전체")
|
|
||||||
-- → 533두 유전체 분석 데이터 (CSV)
|
|
||||||
-- → tb_genome_cow, tb_genome_trait 테이블에 저장
|
|
||||||
--
|
|
||||||
-- 3. SNP 타이핑 결과 업로드 (fileType: "유전자")
|
|
||||||
-- → 개체별 SNP 유전자형
|
|
||||||
-- → tb_snp_cow 테이블에 저장
|
|
||||||
--
|
|
||||||
-- 4. MPT 분석결과 업로드 (fileType: "혈액대사검사", 선택사항)
|
|
||||||
-- → 혈액 샘플 분석 결과
|
|
||||||
-- → tb_repro_mpt 테이블에 저장
|
|
||||||
--
|
|
||||||
-- 업로드 후 확인 쿼리:
|
|
||||||
-- SELECT * FROM tb_uploadfile WHERE file_type IN ('유전자', '유전체', '혈액대사검사') ORDER BY reg_dt DESC;
|
|
||||||
-- SELECT count(*) FROM tb_cow;
|
|
||||||
-- SELECT count(*) FROM tb_genome_cow;
|
|
||||||
-- SELECT count(*) FROM tb_snp_cow;
|
|
||||||
-- SELECT farm_owner, count(*) FROM tb_cow GROUP BY farm_owner;
|
|
||||||
|
|
||||||
-- ============================================
|
|
||||||
-- 완료
|
|
||||||
-- ============================================
|
|
||||||
-- v3 seed 데이터 생성 완료
|
|
||||||
-- 마스터 데이터만 포함, 실제 개체 데이터는 파일 업로드로 생성 예정
|
|
||||||
@@ -7,16 +7,12 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
import { CommonModule } from './common/common.module';
|
import { CommonModule } from './common/common.module';
|
||||||
import { SharedModule } from './shared/shared.module';
|
import { SharedModule } from './shared/shared.module';
|
||||||
import { HelpModule } from './help/help.module';
|
|
||||||
import { JwtModule } from './common/jwt/jwt.module';
|
import { JwtModule } from './common/jwt/jwt.module';
|
||||||
import { JwtStrategy } from './common/jwt/jwt.strategy';
|
import { JwtStrategy } from './common/jwt/jwt.strategy';
|
||||||
|
|
||||||
// 새로 생성한 모듈들
|
|
||||||
import { FarmModule } from './farm/farm.module';
|
import { FarmModule } from './farm/farm.module';
|
||||||
import { CowModule } from './cow/cow.module';
|
import { CowModule } from './cow/cow.module';
|
||||||
import { GenomeModule } from './genome/genome.module';
|
import { GenomeModule } from './genome/genome.module';
|
||||||
import { MptModule } from './mpt/mpt.module';
|
import { MptModule } from './mpt/mpt.module';
|
||||||
import { DashboardModule } from './dashboard/dashboard.module';
|
|
||||||
import { GeneModule } from './gene/gene.module';
|
import { GeneModule } from './gene/gene.module';
|
||||||
import { SystemModule } from './system/system.module';
|
import { SystemModule } from './system/system.module';
|
||||||
|
|
||||||
@@ -56,10 +52,8 @@ import { SystemModule } from './system/system.module';
|
|||||||
GenomeModule,
|
GenomeModule,
|
||||||
GeneModule,
|
GeneModule,
|
||||||
MptModule,
|
MptModule,
|
||||||
DashboardModule,
|
|
||||||
|
|
||||||
// 기타
|
// 기타
|
||||||
HelpModule,
|
|
||||||
SystemModule,
|
SystemModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -77,18 +77,12 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const accessToken = this.jwtService.sign(payload as any);
|
const accessToken = this.jwtService.sign(payload as any);
|
||||||
const refreshOptions = {
|
|
||||||
secret: this.configService.get<string>('JWT_REFRESH_SECRET')!,
|
|
||||||
expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') || '7d',
|
|
||||||
};
|
|
||||||
const refreshToken = this.jwtService.sign(payload as any, refreshOptions as any);
|
|
||||||
|
|
||||||
this.logger.log(`[LOGIN] 로그인 성공 - userId: ${userId}`);
|
this.logger.log(`[LOGIN] 로그인 성공 - userId: ${userId}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: '로그인 성공',
|
message: '로그인 성공',
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
|
||||||
user: {
|
user: {
|
||||||
pkUserNo: user.pkUserNo,
|
pkUserNo: user.pkUserNo,
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
export class LoginResponseDto {
|
export class LoginResponseDto {
|
||||||
message: string;
|
message: string;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
refreshToken?: string;
|
|
||||||
user: {
|
user: {
|
||||||
pkUserNo: number;
|
pkUserNo: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CommonController } from './common.controller';
|
import { CommonController } from './common.controller';
|
||||||
import { CommonService } from './common.service';
|
import { CommonService } from './common.service';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { JwtStrategy } from './jwt/jwt.strategy';
|
import { JwtStrategy } from './jwt/jwt.strategy';
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
import { HttpExceptionFilter } from './filters/http-exception.filter';
|
|
||||||
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
|
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
|
||||||
import { LoggingInterceptor } from './interceptors/logging.interceptor';
|
import { LoggingInterceptor } from './interceptors/logging.interceptor';
|
||||||
import { TransformInterceptor } from './interceptors/transform.interceptor';
|
import { TransformInterceptor } from './interceptors/transform.interceptor';
|
||||||
@@ -15,7 +13,6 @@ import { TransformInterceptor } from './interceptors/transform.interceptor';
|
|||||||
CommonService,
|
CommonService,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
JwtAuthGuard,
|
JwtAuthGuard,
|
||||||
HttpExceptionFilter,
|
|
||||||
AllExceptionsFilter,
|
AllExceptionsFilter,
|
||||||
LoggingInterceptor,
|
LoggingInterceptor,
|
||||||
TransformInterceptor,
|
TransformInterceptor,
|
||||||
@@ -23,7 +20,6 @@ import { TransformInterceptor } from './interceptors/transform.interceptor';
|
|||||||
exports: [
|
exports: [
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
JwtAuthGuard,
|
JwtAuthGuard,
|
||||||
HttpExceptionFilter,
|
|
||||||
AllExceptionsFilter,
|
AllExceptionsFilter,
|
||||||
LoggingInterceptor,
|
LoggingInterceptor,
|
||||||
TransformInterceptor,
|
TransformInterceptor,
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
/**
|
|
||||||
* 근친도 관련 설정 상수
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* Wright's Coefficient of Inbreeding 알고리즘 기반 근친도 계산 및 위험도 판정 기준
|
|
||||||
*
|
|
||||||
* @source PRD 기능요구사항20.md SFR-COW-016-3
|
|
||||||
* @reference Wright, S. (1922). Coefficients of Inbreeding and Relationship
|
|
||||||
*/
|
|
||||||
export const INBREEDING_CONFIG = {
|
|
||||||
/**
|
|
||||||
* 위험도 판정 기준 (%)
|
|
||||||
* - 정상: < 15%
|
|
||||||
* - 주의: 15-20%
|
|
||||||
* - 위험: > 20%
|
|
||||||
*/
|
|
||||||
RISK_LEVELS: {
|
|
||||||
NORMAL_MAX: 15, // 정상 상한선 (< 15%)
|
|
||||||
WARNING_MIN: 15, // 주의 하한선 (>= 15%)
|
|
||||||
WARNING_MAX: 20, // 주의 상한선 (<= 20%)
|
|
||||||
DANGER_MIN: 20, // 위험 하한선 (> 20%)
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 다세대 시뮬레이션 위험도 판정 기준 (%)
|
|
||||||
* - 정상: < 6.25%
|
|
||||||
* - 주의: 6.25% ~ 임계값
|
|
||||||
* - 위험: > 임계값
|
|
||||||
*/
|
|
||||||
MULTI_GENERATION_RISK_LEVELS: {
|
|
||||||
SAFE_MAX: 6.25, // 안전 상한선 (< 6.25%)
|
|
||||||
// WARNING: 6.25% ~ inbreedingThreshold (사용자 지정)
|
|
||||||
// DANGER: > inbreedingThreshold (사용자 지정)
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 기본 근친도 임계값 (%)
|
|
||||||
* Wright's Coefficient 기준 안전 임계값
|
|
||||||
*/
|
|
||||||
DEFAULT_THRESHOLD: 12.5,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 세대별 근친도 영향 감소율
|
|
||||||
* - 1세대: 100% 영향
|
|
||||||
* - 2세대: 50% 영향 (1/2)
|
|
||||||
* - 3세대: 25% 영향 (1/4)
|
|
||||||
* - 4세대: 12.5% 영향 (1/8)
|
|
||||||
*/
|
|
||||||
GENERATION_DECAY: {
|
|
||||||
GEN_1: 1.0, // 100%
|
|
||||||
GEN_2: 0.5, // 50%
|
|
||||||
GEN_3: 0.25, // 25%
|
|
||||||
GEN_4: 0.125, // 12.5%
|
|
||||||
GEN_5: 0.0625, // 6.25%
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* KPN 순환 전략 설정
|
|
||||||
*/
|
|
||||||
ROTATION_STRATEGY: {
|
|
||||||
CYCLE_GENERATIONS: 2, // N세대마다 순환 (기본값: 2세대)
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 유리형 비율 평가 기준 (%)
|
|
||||||
*/
|
|
||||||
FAVORABLE_RATE_THRESHOLDS: {
|
|
||||||
EXCELLENT: 75, // 매우 우수 (>= 75%)
|
|
||||||
GOOD: 60, // 양호 (>= 60%)
|
|
||||||
AVERAGE: 50, // 보통 (>= 50%)
|
|
||||||
POOR: 70, // 권장사항 생성 기준 (>= 70%)
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
/**
|
|
||||||
* MPT 혈액대사검사 정상 범위 기준값
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 각 MPT 항목별 권장 정상 범위를 정의합니다.
|
|
||||||
* 이 범위 내에 있으면 "우수" 판정을 받습니다.
|
|
||||||
*
|
|
||||||
* @export
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
export const MPT_NORMAL_RANGES = {
|
|
||||||
// ========== 에너지 카테고리 ==========
|
|
||||||
/**
|
|
||||||
* 혈당 (Glucose)
|
|
||||||
* 단위: mg/dL
|
|
||||||
*/
|
|
||||||
glucose: { min: 40, max: 84 },
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 콜레스테롤 (Cholesterol)
|
|
||||||
* 단위: mg/dL
|
|
||||||
*/
|
|
||||||
cholesterol: { min: 74, max: 252 },
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 유리지방산 (NEFA)
|
|
||||||
* 단위: μEq/L
|
|
||||||
*/
|
|
||||||
nefa: { min: 115, max: 660 },
|
|
||||||
|
|
||||||
// ========== 단백질 카테고리 ==========
|
|
||||||
/**
|
|
||||||
* 총단백질 (Total Protein)
|
|
||||||
* 단위: g/dL
|
|
||||||
*/
|
|
||||||
totalProtein: { min: 6.2, max: 7.7 },
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 알부민 (Albumin)
|
|
||||||
* 단위: g/dL
|
|
||||||
*/
|
|
||||||
albumin: { min: 3.3, max: 4.3 },
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 총 글로불린 (Total Globulin)
|
|
||||||
* 단위: g/dL
|
|
||||||
*/
|
|
||||||
globulin: { min: 9.1, max: 36.1 },
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A/G 비율 (Albumin/Globulin Ratio)
|
|
||||||
* 단위: 비율
|
|
||||||
*/
|
|
||||||
agRatio: { min: 0.1, max: 0.4 },
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 혈중요소질소 (Blood Urea Nitrogen)
|
|
||||||
* 단위: mg/dL
|
|
||||||
*/
|
|
||||||
bun: { min: 11.7, max: 18.9 },
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AST (Aspartate Aminotransferase)
|
|
||||||
* 단위: U/L
|
|
||||||
*/
|
|
||||||
ast: { min: 47, max: 92 },
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GGT (Gamma-Glutamyl Transferase)
|
|
||||||
* 단위: U/L
|
|
||||||
*/
|
|
||||||
ggt: { min: 11, max: 32 },
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 지방간 지수 (Fatty Liver Index)
|
|
||||||
* 단위: 지수
|
|
||||||
*/
|
|
||||||
fattyLiverIdx: { min: -1.2, max: 9.9 },
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 칼슘 (Calcium)
|
|
||||||
* 단위: mg/dL
|
|
||||||
*/
|
|
||||||
calcium: { min: 8.1, max: 10.6 },
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 인 (Phosphorus)
|
|
||||||
* 단위: mg/dL
|
|
||||||
*/
|
|
||||||
phosphorus: { min: 6.2, max: 8.9 },
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ca/P 비율 (Calcium/Phosphorus Ratio)
|
|
||||||
* 단위: 비율
|
|
||||||
*/
|
|
||||||
caPRatio: { min: 1.2, max: 1.3 },
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마그네슘 (Magnesium)
|
|
||||||
* 단위: mg/dL
|
|
||||||
*/
|
|
||||||
magnesium: { min: 1.6, max: 3.3 },
|
|
||||||
|
|
||||||
// ========== 기타 카테고리 ==========
|
|
||||||
/**
|
|
||||||
* 크레아틴 (Creatine)
|
|
||||||
* 단위: mg/dL
|
|
||||||
*/
|
|
||||||
creatine: { min: 1.0, max: 1.3 },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MPT 항목 타입
|
|
||||||
*/
|
|
||||||
export type MptCriteriaKey = keyof typeof MPT_NORMAL_RANGES;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MPT 범위 타입
|
|
||||||
*/
|
|
||||||
export interface MptRange {
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// 계정 상태 Enum
|
|
||||||
export enum AccountStatusType {
|
|
||||||
ACTIVE = "ACTIVE", // 정상
|
|
||||||
INACTIVE = "INACTIVE", // 비활성
|
|
||||||
SUSPENDED = "SUSPENDED", // 정지
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// 개체 타입 Enum
|
|
||||||
export enum AnimalType {
|
|
||||||
COW = 'COW', // 개체
|
|
||||||
KPN = 'KPN', // KPN
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* 분석 현황 상태 값 Enum
|
|
||||||
*
|
|
||||||
* @export
|
|
||||||
* @enum {number}
|
|
||||||
*/
|
|
||||||
export enum AnlysStatType {
|
|
||||||
MATCH = '친자일치',
|
|
||||||
MISMATCH = '친자불일치',
|
|
||||||
IMPOSSIBLE = '분석불가',
|
|
||||||
NO_HISTORY = '이력제부재',
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* 사육/도태 추천 타입 Enum
|
|
||||||
*
|
|
||||||
* @export
|
|
||||||
* @enum {string}
|
|
||||||
*/
|
|
||||||
export enum BreedingRecommendationType {
|
|
||||||
/** 사육 추천 */
|
|
||||||
BREED = '사육추천',
|
|
||||||
|
|
||||||
/** 도태 추천 */
|
|
||||||
CULL = '도태추천',
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// 개체 번식 타입 Enum
|
|
||||||
export enum CowReproType {
|
|
||||||
DONOR = "공란우",
|
|
||||||
RECIPIENT = "수란우",
|
|
||||||
AI = "인공수정",
|
|
||||||
CULL = "도태대상",
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// 개체 상태 Enum
|
|
||||||
export enum CowStatusType {
|
|
||||||
NORMAL = "정상",
|
|
||||||
DEAD = "폐사",
|
|
||||||
SLAUGHTER = "도축",
|
|
||||||
SALE = "매각",
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* 파일 타입 Enum
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 엑셀 업로드 시 지원되는 파일 유형을 정의합니다.
|
|
||||||
* 각 파일 유형별로 고유한 파싱 로직과 대상 테이블이 매핑됩니다.
|
|
||||||
*
|
|
||||||
* @reference SFR-ADMIN-001 (기능요구사항20.md)
|
|
||||||
*
|
|
||||||
* 파일 유형별 매핑:
|
|
||||||
* - COW: 개체(암소) 정보 → tb_cow
|
|
||||||
* - GENE: 유전자(SNP) 데이터 → tb_snp_cow
|
|
||||||
* - GENOME: 유전체(유전능력) 데이터 → tb_genome_cow
|
|
||||||
* - MPT: 혈액대사검사(MPT) (1행: 카테고리, 2행: 항목명, 3행~: 데이터) → tb_repro_mpt, tb_repro_mpt_item
|
|
||||||
* - FERTILITY: 수태율 데이터 → tb_fertility_rate
|
|
||||||
* - KPN_GENE: KPN 유전자 데이터 → tb_kpn_snp
|
|
||||||
* - KPN_GENOME: KPN 유전체 데이터 → tb_kpn_genome
|
|
||||||
* - KPN_MPT: KPN 혈액대사검사 → tb_kpn_mpt
|
|
||||||
* - REGION_COW: 지역 개체 정보 → tb_region_cow
|
|
||||||
* - REGION_GENE: 지역 유전자 데이터 → tb_region_snp
|
|
||||||
* - REGION_GENOME: 지역 유전체 데이터 → tb_region_genome
|
|
||||||
* - REGION_MPT: 지역 혈액대사검사 → tb_region_mpt
|
|
||||||
* - HELP: 도움말 데이터 (유전자/유전체/번식능력 설명) → tb_help_content
|
|
||||||
* - MARKER: 마커(유전자) 정보 (마커명, 관련형질, 목표유전자형 등) → tb_marker
|
|
||||||
* 목표유전자형(target_genotype): KPN 추천 시 각 유전자의 우량형 기준 (AA, GG, CC 등)
|
|
||||||
*/
|
|
||||||
export enum FileType {
|
|
||||||
// 개체(암소) 데이터
|
|
||||||
COW = '개체',
|
|
||||||
|
|
||||||
// 유전 데이터
|
|
||||||
GENE = '유전자',
|
|
||||||
GENOME = '유전체',
|
|
||||||
|
|
||||||
// 번식 데이터
|
|
||||||
MPT = '혈액대사검사',
|
|
||||||
FERTILITY = '수태율',
|
|
||||||
|
|
||||||
// KPN 데이터
|
|
||||||
KPN_GENE = 'KPN유전자',
|
|
||||||
KPN_GENOME = 'KPN유전체',
|
|
||||||
KPN_MPT = 'KPN혈액대사검사',
|
|
||||||
|
|
||||||
// 지역 개체 데이터 (보은군 비교용)
|
|
||||||
REGION_COW = '지역개체',
|
|
||||||
REGION_GENE = '지역유전자',
|
|
||||||
REGION_GENOME = '지역유전체',
|
|
||||||
REGION_MPT = '지역혈액대사검사',
|
|
||||||
|
|
||||||
// 도움말 데이터
|
|
||||||
HELP = '도움말',
|
|
||||||
|
|
||||||
// 마커(유전자) 정보
|
|
||||||
MARKER = '마커정보',
|
|
||||||
}
|
|
||||||
@@ -1,179 +1,257 @@
|
|||||||
/**
|
/**
|
||||||
* MPT (혈액대사판정시험) 항목별 권장치 참고 범위
|
* MPT (혈액대사판정시험) 항목별 권장치 참고 범위
|
||||||
* MPT 참조값의 중앙 관리 파일
|
* 백엔드 중앙 관리 파일 - 프론트엔드에서 API로 조회
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface MptReferenceRange {
|
export interface MptReferenceRange {
|
||||||
|
key: string;
|
||||||
name: string; // 한글 표시명
|
name: string; // 한글 표시명
|
||||||
upperLimit: number | null;
|
upperLimit: number | null;
|
||||||
lowerLimit: number | null;
|
lowerLimit: number | null;
|
||||||
unit: string;
|
unit: string;
|
||||||
category: '에너지' | '단백질' | '간기능' | '미네랄' | '별도';
|
category: 'energy' | 'protein' | 'liver' | 'mineral' | 'etc';
|
||||||
|
categoryName: string; // 카테고리 한글명
|
||||||
description?: string; // 항목 설명 (선택)
|
description?: string; // 항목 설명 (선택)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MptCategory {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MPT 참조값 범위
|
||||||
|
*/
|
||||||
export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
|
export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
|
||||||
// 에너지 카테고리
|
// 에너지 카테고리
|
||||||
glucose: {
|
glucose: {
|
||||||
|
key: 'glucose',
|
||||||
name: '혈당',
|
name: '혈당',
|
||||||
upperLimit: 84,
|
upperLimit: 84,
|
||||||
lowerLimit: 40,
|
lowerLimit: 40,
|
||||||
unit: 'mg/dL',
|
unit: 'mg/dL',
|
||||||
category: '에너지',
|
category: 'energy',
|
||||||
|
categoryName: '에너지 대사',
|
||||||
description: '에너지 대사 상태 지표',
|
description: '에너지 대사 상태 지표',
|
||||||
},
|
},
|
||||||
cholesterol: {
|
cholesterol: {
|
||||||
|
key: 'cholesterol',
|
||||||
name: '콜레스테롤',
|
name: '콜레스테롤',
|
||||||
upperLimit: 252,
|
upperLimit: 252,
|
||||||
lowerLimit: 74,
|
lowerLimit: 74,
|
||||||
unit: 'mg/dL',
|
unit: 'mg/dL',
|
||||||
category: '에너지',
|
category: 'energy',
|
||||||
|
categoryName: '에너지 대사',
|
||||||
description: '혈액 내 콜레스테롤 수치',
|
description: '혈액 내 콜레스테롤 수치',
|
||||||
},
|
},
|
||||||
nefa: {
|
nefa: {
|
||||||
|
key: 'nefa',
|
||||||
name: '유리지방산(NEFA)',
|
name: '유리지방산(NEFA)',
|
||||||
upperLimit: 660,
|
upperLimit: 660,
|
||||||
lowerLimit: 115,
|
lowerLimit: 115,
|
||||||
unit: 'μEq/L',
|
unit: 'μEq/L',
|
||||||
category: '에너지',
|
category: 'energy',
|
||||||
|
categoryName: '에너지 대사',
|
||||||
description: '혈액 내 유리지방산 수치',
|
description: '혈액 내 유리지방산 수치',
|
||||||
},
|
},
|
||||||
bcs: {
|
bcs: {
|
||||||
|
key: 'bcs',
|
||||||
name: 'BCS',
|
name: 'BCS',
|
||||||
upperLimit: 3.5,
|
upperLimit: 3.5,
|
||||||
lowerLimit: 2.5,
|
lowerLimit: 2.5,
|
||||||
unit: '-',
|
unit: '점',
|
||||||
category: '에너지',
|
category: 'energy',
|
||||||
|
categoryName: '에너지 대사',
|
||||||
description: '체충실지수(Body Condition Score)',
|
description: '체충실지수(Body Condition Score)',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 단백질 카테고리
|
// 단백질 카테고리
|
||||||
totalProtein: {
|
totalProtein: {
|
||||||
|
key: 'totalProtein',
|
||||||
name: '총단백질',
|
name: '총단백질',
|
||||||
upperLimit: 7.7,
|
upperLimit: 7.7,
|
||||||
lowerLimit: 6.2,
|
lowerLimit: 6.2,
|
||||||
unit: 'g/dL',
|
unit: 'g/dL',
|
||||||
category: '단백질',
|
category: 'protein',
|
||||||
|
categoryName: '단백질 대사',
|
||||||
description: '혈액 내 총단백질 수치',
|
description: '혈액 내 총단백질 수치',
|
||||||
},
|
},
|
||||||
albumin: {
|
albumin: {
|
||||||
|
key: 'albumin',
|
||||||
name: '알부민',
|
name: '알부민',
|
||||||
upperLimit: 4.3,
|
upperLimit: 4.3,
|
||||||
lowerLimit: 3.3,
|
lowerLimit: 3.3,
|
||||||
unit: 'g/dL',
|
unit: 'g/dL',
|
||||||
category: '단백질',
|
category: 'protein',
|
||||||
|
categoryName: '단백질 대사',
|
||||||
description: '혈액 내 알부민 수치',
|
description: '혈액 내 알부민 수치',
|
||||||
},
|
},
|
||||||
globulin: {
|
globulin: {
|
||||||
|
key: 'globulin',
|
||||||
name: '총글로불린',
|
name: '총글로불린',
|
||||||
upperLimit: 36.1,
|
upperLimit: 36.1,
|
||||||
lowerLimit: 9.1,
|
lowerLimit: 9.1,
|
||||||
unit: 'g/dL',
|
unit: 'g/dL',
|
||||||
category: '단백질',
|
category: 'protein',
|
||||||
|
categoryName: '단백질 대사',
|
||||||
description: '혈액 내 총글로불린 수치',
|
description: '혈액 내 총글로불린 수치',
|
||||||
},
|
},
|
||||||
agRatio: {
|
agRatio: {
|
||||||
name: 'A/G ',
|
key: 'agRatio',
|
||||||
|
name: 'A/G 비율',
|
||||||
upperLimit: 0.4,
|
upperLimit: 0.4,
|
||||||
lowerLimit: 0.1,
|
lowerLimit: 0.1,
|
||||||
unit: '-',
|
unit: '',
|
||||||
category: '단백질',
|
category: 'protein',
|
||||||
description: '혈액 내 A/G 수치',
|
categoryName: '단백질 대사',
|
||||||
|
description: '알부민/글로불린 비율',
|
||||||
},
|
},
|
||||||
bun: {
|
bun: {
|
||||||
|
key: 'bun',
|
||||||
name: '요소태질소(BUN)',
|
name: '요소태질소(BUN)',
|
||||||
upperLimit: 18.9,
|
upperLimit: 18.9,
|
||||||
lowerLimit: 11.7,
|
lowerLimit: 11.7,
|
||||||
unit: 'mg/dL',
|
unit: 'mg/dL',
|
||||||
category: '단백질',
|
category: 'protein',
|
||||||
|
categoryName: '단백질 대사',
|
||||||
description: '혈액 내 요소태질소 수치',
|
description: '혈액 내 요소태질소 수치',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 간기능 카테고리
|
// 간기능 카테고리
|
||||||
ast: {
|
ast: {
|
||||||
|
key: 'ast',
|
||||||
name: 'AST',
|
name: 'AST',
|
||||||
upperLimit: 92,
|
upperLimit: 92,
|
||||||
lowerLimit: 47,
|
lowerLimit: 47,
|
||||||
unit: 'U/L',
|
unit: 'U/L',
|
||||||
category: '간기능',
|
category: 'liver',
|
||||||
|
categoryName: '간기능',
|
||||||
description: '혈액 내 AST 수치',
|
description: '혈액 내 AST 수치',
|
||||||
},
|
},
|
||||||
ggt: {
|
ggt: {
|
||||||
|
key: 'ggt',
|
||||||
name: 'GGT',
|
name: 'GGT',
|
||||||
upperLimit: 32,
|
upperLimit: 32,
|
||||||
lowerLimit: 11,
|
lowerLimit: 11,
|
||||||
unit: 'U/L',
|
unit: 'U/L',
|
||||||
category: '간기능',
|
category: 'liver',
|
||||||
|
categoryName: '간기능',
|
||||||
description: '혈액 내 GGT 수치',
|
description: '혈액 내 GGT 수치',
|
||||||
},
|
},
|
||||||
fattyLiverIdx: {
|
fattyLiverIdx: {
|
||||||
|
key: 'fattyLiverIdx',
|
||||||
name: '지방간 지수',
|
name: '지방간 지수',
|
||||||
upperLimit: 9.9,
|
upperLimit: 9.9,
|
||||||
lowerLimit: -1.2,
|
lowerLimit: -1.2,
|
||||||
unit: '-',
|
unit: '',
|
||||||
category: '간기능',
|
category: 'liver',
|
||||||
|
categoryName: '간기능',
|
||||||
description: '혈액 내 지방간 지수 수치',
|
description: '혈액 내 지방간 지수 수치',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 미네랄 카테고리
|
// 미네랄 카테고리
|
||||||
calcium: {
|
calcium: {
|
||||||
|
key: 'calcium',
|
||||||
name: '칼슘',
|
name: '칼슘',
|
||||||
upperLimit: 10.6,
|
upperLimit: 10.6,
|
||||||
lowerLimit: 8.1,
|
lowerLimit: 8.1,
|
||||||
unit: 'mg/dL',
|
unit: 'mg/dL',
|
||||||
category: '미네랄',
|
category: 'mineral',
|
||||||
|
categoryName: '미네랄',
|
||||||
description: '혈액 내 칼슘 수치',
|
description: '혈액 내 칼슘 수치',
|
||||||
},
|
},
|
||||||
phosphorus: {
|
phosphorus: {
|
||||||
|
key: 'phosphorus',
|
||||||
name: '인',
|
name: '인',
|
||||||
upperLimit: 8.9,
|
upperLimit: 8.9,
|
||||||
lowerLimit: 6.2,
|
lowerLimit: 6.2,
|
||||||
unit: 'mg/dL',
|
unit: 'mg/dL',
|
||||||
category: '미네랄',
|
category: 'mineral',
|
||||||
|
categoryName: '미네랄',
|
||||||
description: '혈액 내 인 수치',
|
description: '혈액 내 인 수치',
|
||||||
},
|
},
|
||||||
caPRatio: {
|
caPRatio: {
|
||||||
|
key: 'caPRatio',
|
||||||
name: '칼슘/인 비율',
|
name: '칼슘/인 비율',
|
||||||
upperLimit: 1.3,
|
upperLimit: 1.3,
|
||||||
lowerLimit: 1.2,
|
lowerLimit: 1.2,
|
||||||
unit: '-',
|
unit: '',
|
||||||
category: '미네랄',
|
category: 'mineral',
|
||||||
description: '혈액 내 칼슘/인 비율 수치',
|
categoryName: '미네랄',
|
||||||
|
description: '혈액 내 칼슘/인 비율',
|
||||||
},
|
},
|
||||||
magnesium: {
|
magnesium: {
|
||||||
|
key: 'magnesium',
|
||||||
name: '마그네슘',
|
name: '마그네슘',
|
||||||
upperLimit: 3.3,
|
upperLimit: 3.3,
|
||||||
lowerLimit: 1.6,
|
lowerLimit: 1.6,
|
||||||
unit: 'mg/dL',
|
unit: 'mg/dL',
|
||||||
category: '미네랄',
|
category: 'mineral',
|
||||||
|
categoryName: '미네랄',
|
||||||
description: '혈액 내 마그네슘 수치',
|
description: '혈액 내 마그네슘 수치',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 별도 카테고리
|
// 별도 카테고리
|
||||||
creatine: {
|
creatine: {
|
||||||
|
key: 'creatine',
|
||||||
name: '크레아틴',
|
name: '크레아틴',
|
||||||
upperLimit: 1.3,
|
upperLimit: 1.3,
|
||||||
lowerLimit: 1.0,
|
lowerLimit: 1.0,
|
||||||
unit: 'mg/dL',
|
unit: 'mg/dL',
|
||||||
category: '별도',
|
category: 'etc',
|
||||||
|
categoryName: '기타',
|
||||||
description: '혈액 내 크레아틴 수치',
|
description: '혈액 내 크레아틴 수치',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MPT 카테고리 목록 (표시 순서)
|
* MPT 카테고리 목록
|
||||||
*/
|
*/
|
||||||
export const MPT_CATEGORIES = ['에너지', '단백질', '간기능', '미네랄', '별도'] as const;
|
export const MPT_CATEGORIES: MptCategory[] = [
|
||||||
|
{
|
||||||
|
key: 'energy',
|
||||||
|
name: '에너지 대사',
|
||||||
|
color: 'bg-orange-500',
|
||||||
|
items: ['glucose', 'cholesterol', 'nefa', 'bcs'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'protein',
|
||||||
|
name: '단백질 대사',
|
||||||
|
color: 'bg-blue-500',
|
||||||
|
items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'liver',
|
||||||
|
name: '간기능',
|
||||||
|
color: 'bg-green-500',
|
||||||
|
items: ['ast', 'ggt', 'fattyLiverIdx'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mineral',
|
||||||
|
name: '미네랄',
|
||||||
|
color: 'bg-purple-500',
|
||||||
|
items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'etc',
|
||||||
|
name: '기타',
|
||||||
|
color: 'bg-gray-500',
|
||||||
|
items: ['creatine'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 측정값이 정상 범위 내에 있는지 확인
|
* 측정값이 정상 범위 내에 있는지 확인
|
||||||
*/
|
*/
|
||||||
export function isWithinRange(
|
export function checkMptStatus(
|
||||||
value: number,
|
value: number | null,
|
||||||
itemKey: string
|
itemKey: string,
|
||||||
): 'normal' | 'high' | 'low' | 'unknown' {
|
): 'normal' | 'high' | 'low' | 'unknown' {
|
||||||
const reference = MPT_REFERENCE_RANGES[itemKey];
|
if (value === null || value === undefined) return 'unknown';
|
||||||
|
|
||||||
|
const reference = MPT_REFERENCE_RANGES[itemKey];
|
||||||
if (!reference || reference.upperLimit === null || reference.lowerLimit === null) {
|
if (!reference || reference.upperLimit === null || reference.lowerLimit === null) {
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
@@ -182,20 +260,3 @@ export function isWithinRange(
|
|||||||
if (value < reference.lowerLimit) return 'low';
|
if (value < reference.lowerLimit) return 'low';
|
||||||
return 'normal';
|
return 'normal';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 카테고리별로 MPT 항목 그룹화
|
|
||||||
*/
|
|
||||||
export function getMptItemsByCategory() {
|
|
||||||
const grouped: Record<string, string[]> = {};
|
|
||||||
|
|
||||||
MPT_CATEGORIES.forEach((category) => {
|
|
||||||
grouped[category] = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.entries(MPT_REFERENCE_RANGES).forEach(([itemKey, reference]) => {
|
|
||||||
grouped[reference.category].push(itemKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
return grouped;
|
|
||||||
}
|
|
||||||
13
backend/src/common/const/RankingCriteriaType.ts
Normal file
13
backend/src/common/const/RankingCriteriaType.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 랭킹 기준 타입 Enum
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 개체 목록 페이지에서 사용하는 랭킹 기준
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
export enum RankingCriteriaType {
|
||||||
|
/** 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균) */
|
||||||
|
GENOME = 'GENOME',
|
||||||
|
}
|
||||||
109
backend/src/common/const/TraitTypes.ts
Normal file
109
backend/src/common/const/TraitTypes.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 형질(Trait) 관련 상수 정의
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 유전체 분석에서 사용하는 35개 형질 목록
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 성장형질 (1개) */
|
||||||
|
export const GROWTH_TRAITS = ['12개월령체중'] as const;
|
||||||
|
|
||||||
|
/** 경제형질 (4개) - 생산 카테고리 */
|
||||||
|
export const ECONOMIC_TRAITS = ['도체중', '등심단면적', '등지방두께', '근내지방도'] as const;
|
||||||
|
|
||||||
|
/** 체형형질 (10개) */
|
||||||
|
export const BODY_TRAITS = [
|
||||||
|
'체고', '십자', '체장', '흉심', '흉폭',
|
||||||
|
'고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 부위별 무게 (10개) */
|
||||||
|
export const WEIGHT_TRAITS = [
|
||||||
|
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
||||||
|
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 부위별 비율 (10개) */
|
||||||
|
export const RATE_TRAITS = [
|
||||||
|
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
||||||
|
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 전체 형질 (35개) */
|
||||||
|
export const ALL_TRAITS = [
|
||||||
|
...GROWTH_TRAITS,
|
||||||
|
...ECONOMIC_TRAITS,
|
||||||
|
...BODY_TRAITS,
|
||||||
|
...WEIGHT_TRAITS,
|
||||||
|
...RATE_TRAITS,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 낮을수록 좋은 형질 (부호 반전 필요) */
|
||||||
|
export const NEGATIVE_TRAITS: string[] = ['등지방두께'];
|
||||||
|
|
||||||
|
/** 형질 타입 */
|
||||||
|
export type TraitName = typeof ALL_TRAITS[number];
|
||||||
|
|
||||||
|
/** 카테고리 타입 */
|
||||||
|
export type TraitCategory = '성장' | '생산' | '체형' | '무게' | '비율';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 형질별 카테고리 매핑
|
||||||
|
* - 형질명 → 카테고리 조회용
|
||||||
|
*/
|
||||||
|
export const TRAIT_CATEGORY_MAP: Record<string, TraitCategory> = {
|
||||||
|
// 성장 카테고리 - 월령별 체중
|
||||||
|
'12개월령체중': '성장',
|
||||||
|
|
||||||
|
// 생산 카테고리 - 도체(도축 후 고기) 품질
|
||||||
|
'도체중': '생산',
|
||||||
|
'등심단면적': '생산',
|
||||||
|
'등지방두께': '생산',
|
||||||
|
'근내지방도': '생산',
|
||||||
|
|
||||||
|
// 체형 카테고리 - 신체 구조
|
||||||
|
'체고': '체형',
|
||||||
|
'십자': '체형',
|
||||||
|
'체장': '체형',
|
||||||
|
'흉심': '체형',
|
||||||
|
'흉폭': '체형',
|
||||||
|
'고장': '체형',
|
||||||
|
'요각폭': '체형',
|
||||||
|
'좌골폭': '체형',
|
||||||
|
'곤폭': '체형',
|
||||||
|
'흉위': '체형',
|
||||||
|
|
||||||
|
// 무게 카테고리 - 부위별 실제 무게 (kg)
|
||||||
|
'안심weight': '무게',
|
||||||
|
'등심weight': '무게',
|
||||||
|
'채끝weight': '무게',
|
||||||
|
'목심weight': '무게',
|
||||||
|
'앞다리weight': '무게',
|
||||||
|
'우둔weight': '무게',
|
||||||
|
'설도weight': '무게',
|
||||||
|
'사태weight': '무게',
|
||||||
|
'양지weight': '무게',
|
||||||
|
'갈비weight': '무게',
|
||||||
|
|
||||||
|
// 비율 카테고리 - 부위별 비율 (%)
|
||||||
|
'안심rate': '비율',
|
||||||
|
'등심rate': '비율',
|
||||||
|
'채끝rate': '비율',
|
||||||
|
'목심rate': '비율',
|
||||||
|
'앞다리rate': '비율',
|
||||||
|
'우둔rate': '비율',
|
||||||
|
'설도rate': '비율',
|
||||||
|
'사태rate': '비율',
|
||||||
|
'양지rate': '비율',
|
||||||
|
'갈비rate': '비율',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 형질명으로 카테고리 조회
|
||||||
|
*
|
||||||
|
* @param traitName - 형질명
|
||||||
|
* @returns 카테고리명 (없으면 '기타')
|
||||||
|
*/
|
||||||
|
export function getTraitCategory(traitName: string): string {
|
||||||
|
return TRAIT_CATEGORY_MAP[traitName] ?? '기타';
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 로그인한 사용자 정보를 가져오는 Decorator
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 인증 미들웨어(JWT, Passport 등)가 req.user에 추가한 사용자 정보를 추출합니다.
|
|
||||||
* 인증되지 않은 경우 기본값을 반환합니다.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // 전체 user 객체 가져오기
|
|
||||||
* async method(@CurrentUser() user: any) {
|
|
||||||
* console.log(user.userId, user.email);
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // 특정 속성만 가져오기
|
|
||||||
* async method(@CurrentUser('userId') userId: string) {
|
|
||||||
* console.log(userId); // 'user123' or 'system'
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export const CurrentUser = createParamDecorator(
|
|
||||||
(data: string | undefined, ctx: ExecutionContext) => {
|
|
||||||
const request = ctx.switchToHttp().getRequest();
|
|
||||||
const user = request.user;
|
|
||||||
|
|
||||||
// 사용자 정보가 없으면 기본값 반환
|
|
||||||
if (!user) {
|
|
||||||
// userId를 요청한 경우 'system' 반환
|
|
||||||
if (data === 'userId') {
|
|
||||||
return 'system';
|
|
||||||
}
|
|
||||||
// 전체 user 객체를 요청한 경우 null 반환
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 특정 속성을 요청한 경우 해당 속성 반환
|
|
||||||
// 전체 user 객체를 요청한 경우 user 반환
|
|
||||||
return data ? user[data] : user;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User 데코레이터
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* JWT 인증 후 Request 객체에서 사용자 정보를 추출하는 데코레이터입니다.
|
|
||||||
* @Req() req 대신 사용하여 더 간결하게 사용자 정보를 가져올 수 있습니다.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // 전체 사용자 정보 가져오기
|
|
||||||
* @Get('profile')
|
|
||||||
* @UseGuards(JwtAuthGuard)
|
|
||||||
* getProfile(@User() user: any) {
|
|
||||||
* return user; // { userId: '...', userNo: 1, role: 'user' }
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // 특정 필드만 가져오기
|
|
||||||
* @Get('my-data')
|
|
||||||
* @UseGuards(JwtAuthGuard)
|
|
||||||
* getMyData(@User('userId') userId: string) {
|
|
||||||
* return `Your ID is ${userId}`;
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @export
|
|
||||||
* @constant User
|
|
||||||
*/
|
|
||||||
export const User = createParamDecorator(
|
|
||||||
(data: string, ctx: ExecutionContext) => {
|
|
||||||
const request = ctx.switchToHttp().getRequest();
|
|
||||||
const user = request.user;
|
|
||||||
|
|
||||||
// 특정 필드만 반환
|
|
||||||
return data ? user?.[data] : user;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import {
|
|
||||||
ExceptionFilter,
|
|
||||||
Catch,
|
|
||||||
ArgumentsHost,
|
|
||||||
HttpException,
|
|
||||||
HttpStatus,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Request, Response } from 'express';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP 예외 필터
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 모든 HTTP 예외를 잡아서 일관된 형식으로 응답을 반환합니다.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // main.ts에서 전역 적용
|
|
||||||
* app.useGlobalFilters(new HttpExceptionFilter());
|
|
||||||
*
|
|
||||||
* @export
|
|
||||||
* @class HttpExceptionFilter
|
|
||||||
* @implements {ExceptionFilter}
|
|
||||||
*/
|
|
||||||
@Catch(HttpException)
|
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
|
||||||
catch(exception: HttpException, host: ArgumentsHost) {
|
|
||||||
const ctx = host.switchToHttp();
|
|
||||||
const response = ctx.getResponse<Response>();
|
|
||||||
const request = ctx.getRequest<Request>();
|
|
||||||
const status = exception.getStatus();
|
|
||||||
const exceptionResponse = exception.getResponse();
|
|
||||||
|
|
||||||
// 에러 메시지 추출
|
|
||||||
let message: string | string[];
|
|
||||||
if (typeof exceptionResponse === 'string') {
|
|
||||||
message = exceptionResponse;
|
|
||||||
} else if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
|
|
||||||
message = (exceptionResponse as any).message || exception.message;
|
|
||||||
} else {
|
|
||||||
message = exception.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 일관된 에러 응답 형식
|
|
||||||
const errorResponse = {
|
|
||||||
success: false,
|
|
||||||
statusCode: status,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
path: request.url,
|
|
||||||
method: request.method,
|
|
||||||
message: Array.isArray(message) ? message : [message],
|
|
||||||
error: this.getErrorName(status),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 개발 환경에서는 스택 트레이스 포함
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
(errorResponse as any).stack = exception.stack;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 로깅
|
|
||||||
console.error(
|
|
||||||
`[${errorResponse.timestamp}] ${request.method} ${request.url} - ${status}`,
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
|
|
||||||
response.status(status).json(errorResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP 상태 코드에 따른 에러 이름 반환
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @param {number} status - HTTP 상태 코드
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
private getErrorName(status: number): string {
|
|
||||||
switch (status) {
|
|
||||||
case HttpStatus.BAD_REQUEST:
|
|
||||||
return 'Bad Request';
|
|
||||||
case HttpStatus.UNAUTHORIZED:
|
|
||||||
return 'Unauthorized';
|
|
||||||
case HttpStatus.FORBIDDEN:
|
|
||||||
return 'Forbidden';
|
|
||||||
case HttpStatus.NOT_FOUND:
|
|
||||||
return 'Not Found';
|
|
||||||
case HttpStatus.CONFLICT:
|
|
||||||
return 'Conflict';
|
|
||||||
case HttpStatus.INTERNAL_SERVER_ERROR:
|
|
||||||
return 'Internal Server Error';
|
|
||||||
default:
|
|
||||||
return 'Error';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Request } from 'express';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 클라이언트의 실제 IP 주소를 추출합니다.
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* Proxy, Load Balancer, CDN 뒤에 있어도 실제 클라이언트 IP를 정확하게 가져옵니다.
|
|
||||||
* 다음 순서로 IP를 확인합니다:
|
|
||||||
* 1. X-Forwarded-For 헤더 (Proxy/Load Balancer)
|
|
||||||
* 2. X-Real-IP 헤더 (Nginx)
|
|
||||||
* 3. req.ip (Express 기본)
|
|
||||||
* 4. req.socket.remoteAddress (직접 연결)
|
|
||||||
* 5. 'unknown' (IP를 찾을 수 없는 경우)
|
|
||||||
*
|
|
||||||
* @param req - Express Request 객체
|
|
||||||
* @returns 클라이언트 IP 주소
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const ip = getClientIp(req);
|
|
||||||
* console.log(ip); // '203.123.45.67' or 'unknown'
|
|
||||||
*/
|
|
||||||
export function getClientIp(req: Request): string {
|
|
||||||
// 1. X-Forwarded-For 헤더 확인 (Proxy/Load Balancer 환경)
|
|
||||||
// 형식: "client IP, proxy1 IP, proxy2 IP"
|
|
||||||
const forwardedFor = req.headers['x-forwarded-for'];
|
|
||||||
if (forwardedFor) {
|
|
||||||
// 배열이면 첫 번째 요소, 문자열이면 콤마로 split
|
|
||||||
const ips = Array.isArray(forwardedFor)
|
|
||||||
? forwardedFor[0]
|
|
||||||
: forwardedFor.split(',')[0];
|
|
||||||
return ips.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. X-Real-IP 헤더 확인 (Nginx 환경)
|
|
||||||
const realIp = req.headers['x-real-ip'];
|
|
||||||
if (realIp && typeof realIp === 'string') {
|
|
||||||
return realIp.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Express가 제공하는 req.ip
|
|
||||||
if (req.ip) {
|
|
||||||
return req.ip;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Socket의 remoteAddress
|
|
||||||
if (req.socket?.remoteAddress) {
|
|
||||||
return req.socket.remoteAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. IP를 찾을 수 없는 경우
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
@@ -3,37 +3,22 @@
|
|||||||
* 개체(Cow) 컨트롤러
|
* 개체(Cow) 컨트롤러
|
||||||
* ============================================================
|
* ============================================================
|
||||||
*
|
*
|
||||||
* 사용 페이지: 개체 목록 페이지 (/cow)
|
* 사용 페이지: 개체 목록 페이지 (/cow), 개체 상세 페이지 (/cow/:cowNo)
|
||||||
*
|
*
|
||||||
* 엔드포인트:
|
* 엔드포인트:
|
||||||
* - GET /cow - 기본 개체 목록 조회
|
* - GET /cow/:cowId - 개체 상세 조회
|
||||||
* - GET /cow/:id - 개체 상세 조회
|
|
||||||
* - POST /cow/ranking - 랭킹 적용 개체 목록 조회
|
* - POST /cow/ranking - 랭킹 적용 개체 목록 조회
|
||||||
* - POST /cow/ranking/global - 전체 개체 랭킹 조회
|
|
||||||
* ============================================================
|
* ============================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
|
||||||
import { CowService } from './cow.service';
|
import { CowService } from './cow.service';
|
||||||
import { CowModel } from './entities/cow.entity';
|
|
||||||
import { RankingRequestDto } from './dto/ranking-request.dto';
|
import { RankingRequestDto } from './dto/ranking-request.dto';
|
||||||
|
|
||||||
@Controller('cow')
|
@Controller('cow')
|
||||||
export class CowController {
|
export class CowController {
|
||||||
constructor(private readonly cowService: CowService) {}
|
constructor(private readonly cowService: CowService) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /cow
|
|
||||||
* 기본 개체 목록 조회
|
|
||||||
*/
|
|
||||||
@Get()
|
|
||||||
findAll(@Query('farmId') farmId?: string) {
|
|
||||||
if (farmId) {
|
|
||||||
return this.cowService.findByFarmId(+farmId);
|
|
||||||
}
|
|
||||||
return this.cowService.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /cow/ranking
|
* POST /cow/ranking
|
||||||
* 랭킹이 적용된 개체 목록 조회
|
* 랭킹이 적용된 개체 목록 조회
|
||||||
@@ -45,25 +30,6 @@ export class CowController {
|
|||||||
return this.cowService.findAllWithRanking(rankingRequest);
|
return this.cowService.findAllWithRanking(rankingRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /cow/ranking/global
|
|
||||||
* 전체 개체 랭킹 조회 (모든 농장 포함)
|
|
||||||
*
|
|
||||||
* 사용 페이지: 대시보드 (농장 순위 비교)
|
|
||||||
*/
|
|
||||||
@Post('ranking/global')
|
|
||||||
findAllWithGlobalRanking(@Body() rankingRequest: RankingRequestDto) {
|
|
||||||
// farmNo 필터 없이 전체 개체 랭킹 조회
|
|
||||||
const globalRequest = {
|
|
||||||
...rankingRequest,
|
|
||||||
filterOptions: {
|
|
||||||
...rankingRequest.filterOptions,
|
|
||||||
farmNo: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return this.cowService.findAllWithRanking(globalRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /cow/:cowId
|
* GET /cow/:cowId
|
||||||
* 개체 상세 조회 (cowId: 개체식별번호 KOR로 시작)
|
* 개체 상세 조회 (cowId: 개체식별번호 KOR로 시작)
|
||||||
@@ -72,19 +38,4 @@ export class CowController {
|
|||||||
findOne(@Param('cowId') cowId: string) {
|
findOne(@Param('cowId') cowId: string) {
|
||||||
return this.cowService.findByCowId(cowId);
|
return this.cowService.findByCowId(cowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
|
||||||
create(@Body() data: Partial<CowModel>) {
|
|
||||||
return this.cowService.create(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put(':id')
|
|
||||||
update(@Param('id') id: string, @Body() data: Partial<CowModel>) {
|
|
||||||
return this.cowService.update(+id, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
remove(@Param('id') id: string) {
|
|
||||||
return this.cowService.remove(+id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,12 @@
|
|||||||
* 개체(Cow) 서비스
|
* 개체(Cow) 서비스
|
||||||
* ============================================================
|
* ============================================================
|
||||||
*
|
*
|
||||||
* 사용 페이지: 개체 목록 페이지 (/cow)
|
* 사용 페이지: 개체 목록 페이지 (/cow), 개체 상세 페이지 (/cow/:cowNo)
|
||||||
*
|
*
|
||||||
* 주요 기능:
|
* 주요 기능:
|
||||||
* 1. 기본 개체 목록 조회 (findAll, findByFarmId)
|
* 1. 개체 단건 조회 (findByCowId)
|
||||||
* 2. 개체 단건 조회 (findOne, findByCowId)
|
* 2. 랭킹 적용 개체 목록 조회 (findAllWithRanking)
|
||||||
* 3. 랭킹 적용 개체 목록 조회 (findAllWithRanking)
|
|
||||||
* - GENOME: 35개 형질 EBV 가중 평균
|
* - GENOME: 35개 형질 EBV 가중 평균
|
||||||
* 4. 개체 CRUD (create, update, remove)
|
|
||||||
* ============================================================
|
* ============================================================
|
||||||
*/
|
*/
|
||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
@@ -25,16 +23,10 @@ import { FilterEngineService } from '../shared/filter/filter-engine.service';
|
|||||||
import {
|
import {
|
||||||
RankingRequestDto,
|
RankingRequestDto,
|
||||||
RankingCriteriaType,
|
RankingCriteriaType,
|
||||||
TraitRankingCondition,
|
TraitRankingConditionDto,
|
||||||
} from './dto/ranking-request.dto';
|
} from './dto/ranking-request.dto';
|
||||||
import { isValidGenomeAnalysis, EXCLUDED_COW_IDS } from '../common/config/GenomeAnalysisConfig';
|
import { isValidGenomeAnalysis, EXCLUDED_COW_IDS } from '../common/config/GenomeAnalysisConfig';
|
||||||
|
import { ALL_TRAITS, NEGATIVE_TRAITS } from '../common/const/TraitTypes';
|
||||||
/**
|
|
||||||
* 낮을수록 좋은 형질 목록 (부호 반전 필요)
|
|
||||||
* - 등지방두께: 지방이 얇을수록(EBV가 낮을수록) 좋은 형질
|
|
||||||
* - 선발지수 계산 시 EBV 부호를 반전하여 적용
|
|
||||||
*/
|
|
||||||
const NEGATIVE_TRAITS = ['등지방두께'];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 개체(소) 관리 서비스
|
* 개체(소) 관리 서비스
|
||||||
@@ -72,56 +64,9 @@ export class CowService {
|
|||||||
) { }
|
) { }
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 기본 조회 메서드
|
// 개체 조회 메서드
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 개체 목록 조회
|
|
||||||
*
|
|
||||||
* @returns 삭제되지 않은 모든 개체 목록
|
|
||||||
* - farm 관계 데이터 포함
|
|
||||||
* - 등록일(regDt) 기준 내림차순 정렬 (최신순)
|
|
||||||
*/
|
|
||||||
async findAll(): Promise<CowModel[]> {
|
|
||||||
return this.cowRepository.find({
|
|
||||||
where: { delDt: IsNull() }, // 삭제되지 않은 데이터만
|
|
||||||
relations: ['farm'], // 농장 정보 JOIN
|
|
||||||
order: { regDt: 'DESC' }, // 최신순 정렬
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 농장별 개체 목록 조회
|
|
||||||
*
|
|
||||||
* @param farmNo - 농장 PK 번호
|
|
||||||
* @returns 해당 농장의 모든 개체 목록 (최신순)
|
|
||||||
*/
|
|
||||||
async findByFarmId(farmNo: number): Promise<CowModel[]> {
|
|
||||||
return this.cowRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
relations: ['farm'],
|
|
||||||
order: { regDt: 'DESC' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 개체 PK로 단건 조회
|
|
||||||
*
|
|
||||||
* @param id - 개체 PK 번호 (pkCowNo)
|
|
||||||
* @returns 개체 정보 (farm 포함)
|
|
||||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
|
||||||
*/
|
|
||||||
async findOne(id: number): Promise<CowModel> {
|
|
||||||
const cow = await this.cowRepository.findOne({
|
|
||||||
where: { pkCowNo: id, delDt: IsNull() },
|
|
||||||
relations: ['farm'],
|
|
||||||
});
|
|
||||||
if (!cow) {
|
|
||||||
throw new NotFoundException(`Cow #${id} not found`);
|
|
||||||
}
|
|
||||||
return cow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 개체식별번호(cowId)로 단건 조회
|
* 개체식별번호(cowId)로 단건 조회
|
||||||
*
|
*
|
||||||
@@ -187,6 +132,8 @@ export class CowService {
|
|||||||
// Step 3: 랭킹 기준에 따라 분기 처리
|
// Step 3: 랭킹 기준에 따라 분기 처리
|
||||||
switch (criteriaType) {
|
switch (criteriaType) {
|
||||||
// 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
|
// 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
|
||||||
|
// 지금은 유전체 형질만 기반으로 랭킹을 매기고 있음 추후 유전자와 유전체 복합 랭킹 변경될수있음
|
||||||
|
// case 추가 예정
|
||||||
case RankingCriteriaType.GENOME:
|
case RankingCriteriaType.GENOME:
|
||||||
return this.applyGenomeRanking(cows, rankingOptions.traitConditions || [], mptCowIdMap);
|
return this.applyGenomeRanking(cows, rankingOptions.traitConditions || [], mptCowIdMap);
|
||||||
|
|
||||||
@@ -331,20 +278,9 @@ export class CowService {
|
|||||||
*/
|
*/
|
||||||
private async applyGenomeRanking(
|
private async applyGenomeRanking(
|
||||||
cows: CowModel[],
|
cows: CowModel[],
|
||||||
inputTraitConditions: TraitRankingCondition[],
|
inputTraitConditions: TraitRankingConditionDto[],
|
||||||
mptCowIdMap: Map<string, { testDt: string; monthAge: number }>,
|
mptCowIdMap: Map<string, { testDt: string; monthAge: number }>,
|
||||||
): Promise<any> {
|
): 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개 전체 형질 사용 (개체상세, 대시보드와 동일)
|
// traitConditions가 비어있으면 35개 전체 형질 사용 (개체상세, 대시보드와 동일)
|
||||||
const traitConditions = inputTraitConditions && inputTraitConditions.length > 0
|
const traitConditions = inputTraitConditions && inputTraitConditions.length > 0
|
||||||
? inputTraitConditions
|
? inputTraitConditions
|
||||||
@@ -356,7 +292,9 @@ export class CowService {
|
|||||||
// Step 1: 해당 개체의 최신 유전체 분석 의뢰 조회 (친자감별 확인용)
|
// Step 1: 해당 개체의 최신 유전체 분석 의뢰 조회 (친자감별 확인용)
|
||||||
const latestRequest = await this.genomeRequestRepository.findOne({
|
const latestRequest = await this.genomeRequestRepository.findOne({
|
||||||
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
|
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
|
||||||
order: { requestDt: 'DESC', regDt: 'DESC' },
|
order: {
|
||||||
|
requestDt: 'DESC',
|
||||||
|
regDt: 'DESC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 2: 친자감별 확인 - 유효하지 않으면 분석 불가
|
// Step 2: 친자감별 확인 - 유효하지 않으면 분석 불가
|
||||||
@@ -418,7 +356,7 @@ export class CowService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: 가중 합계 계산
|
// Step 4: 가중 합계 계산 ====================================================
|
||||||
let weightedSum = 0; // 가중치 적용된 EBV 합계
|
let weightedSum = 0; // 가중치 적용된 EBV 합계
|
||||||
let totalWeight = 0; // 총 가중치
|
let totalWeight = 0; // 총 가중치
|
||||||
let hasAllTraits = true; // 모든 선택 형질 존재 여부
|
let hasAllTraits = true; // 모든 선택 형질 존재 여부
|
||||||
@@ -457,7 +395,7 @@ export class CowService {
|
|||||||
? weightedSum // 가중 합계 (개체상세, 대시보드와 동일한 방식)
|
? weightedSum // 가중 합계 (개체상세, 대시보드와 동일한 방식)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Step 7: 응답 데이터 구성
|
// Step 7: 응답 데이터 구성 (반환 값)
|
||||||
const mptData = mptCowIdMap.get(cow.cowId);
|
const mptData = mptCowIdMap.get(cow.cowId);
|
||||||
return {
|
return {
|
||||||
entity: {
|
entity: {
|
||||||
@@ -526,45 +464,4 @@ export class CowService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// CRUD 메서드
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 새로운 개체 생성
|
|
||||||
*
|
|
||||||
* @param data - 생성할 개체 데이터
|
|
||||||
* @returns 생성된 개체 엔티티
|
|
||||||
*/
|
|
||||||
async create(data: Partial<CowModel>): Promise<CowModel> {
|
|
||||||
const cow = this.cowRepository.create(data);
|
|
||||||
return this.cowRepository.save(cow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 개체 정보 수정
|
|
||||||
*
|
|
||||||
* @param id - 개체 PK 번호
|
|
||||||
* @param data - 수정할 데이터
|
|
||||||
* @returns 수정된 개체 엔티티
|
|
||||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
|
||||||
*/
|
|
||||||
async update(id: number, data: Partial<CowModel>): Promise<CowModel> {
|
|
||||||
await this.findOne(id); // 존재 여부 확인
|
|
||||||
await this.cowRepository.update(id, data);
|
|
||||||
return this.findOne(id); // 수정된 데이터 반환
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 개체 삭제 (Soft Delete)
|
|
||||||
*
|
|
||||||
* 실제 삭제가 아닌 delDt 컬럼에 삭제 시간 기록
|
|
||||||
*
|
|
||||||
* @param id - 개체 PK 번호
|
|
||||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
|
||||||
*/
|
|
||||||
async remove(id: number): Promise<void> {
|
|
||||||
const cow = await this.findOne(id); // 존재 여부 확인
|
|
||||||
await this.cowRepository.softRemove(cow);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,69 +12,30 @@
|
|||||||
* ============================================================
|
* ============================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
import {
|
||||||
* 랭킹 기준 타입
|
IsEnum,
|
||||||
* - GENOME: 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
|
IsOptional,
|
||||||
*/
|
IsArray,
|
||||||
export enum RankingCriteriaType {
|
IsString,
|
||||||
GENOME = 'GENOME',
|
IsNumber,
|
||||||
}
|
Min,
|
||||||
|
Max,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
FilterCondition,
|
||||||
|
SortOption,
|
||||||
|
PaginationOption,
|
||||||
|
FilterEngineOptions,
|
||||||
|
} from '../../shared/filter/interfaces/filter.interface';
|
||||||
|
import { RankingCriteriaType } from '../../common/const/RankingCriteriaType';
|
||||||
|
|
||||||
|
// Re-export for convenience
|
||||||
|
export { RankingCriteriaType };
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 필터 관련 타입 (FilterEngine에서 사용)
|
// 랭킹 조건 DTO
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export type FilterOperator =
|
|
||||||
| 'eq' // 같음
|
|
||||||
| 'ne' // 같지 않음
|
|
||||||
| 'gt' // 초과
|
|
||||||
| 'gte' // 이상
|
|
||||||
| 'lt' // 미만
|
|
||||||
| 'lte' // 이하
|
|
||||||
| 'like' // 포함 (문자열)
|
|
||||||
| 'in' // 배열 내 포함
|
|
||||||
| 'between'; // 범위
|
|
||||||
|
|
||||||
export type SortOrder = 'ASC' | 'DESC';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필터 조건
|
|
||||||
* 예: { field: 'cowSex', operator: 'eq', value: 'F' }
|
|
||||||
*/
|
|
||||||
export interface FilterCondition {
|
|
||||||
field: string;
|
|
||||||
operator: FilterOperator;
|
|
||||||
value: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 정렬 옵션
|
|
||||||
*/
|
|
||||||
export interface SortOption {
|
|
||||||
field: string;
|
|
||||||
order: SortOrder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 페이지네이션 옵션
|
|
||||||
*/
|
|
||||||
export interface PaginationOption {
|
|
||||||
page: number; // 페이지 번호 (1부터 시작)
|
|
||||||
limit: number; // 페이지당 개수
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필터 엔진 옵션
|
|
||||||
* - 개체 목록 필터링에 사용
|
|
||||||
*/
|
|
||||||
export interface FilterEngineOptions {
|
|
||||||
filters?: FilterCondition[];
|
|
||||||
sorts?: SortOption[];
|
|
||||||
pagination?: PaginationOption;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 랭킹 조건 타입
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,21 +45,62 @@ export interface FilterEngineOptions {
|
|||||||
*
|
*
|
||||||
* 예: { traitNm: '도체중', weight: 8 }
|
* 예: { traitNm: '도체중', weight: 8 }
|
||||||
*/
|
*/
|
||||||
export interface TraitRankingCondition {
|
export class TraitRankingConditionDto {
|
||||||
traitNm: string; // 형질명 (예: '도체중', '근내지방도')
|
@IsString()
|
||||||
weight?: number; // 가중치 1~10 (기본값: 1)
|
traitNm: string; // 형질명 (예: '도체중', '근내지방도')
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(10)
|
||||||
|
weight?: number; // 가중치 1~10 (기본값: 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 랭킹 옵션
|
* 랭킹 옵션 DTO
|
||||||
*/
|
*/
|
||||||
export interface RankingOptions {
|
export class RankingOptionsDto {
|
||||||
criteriaType: RankingCriteriaType; // 랭킹 기준 타입
|
@IsEnum(RankingCriteriaType)
|
||||||
traitConditions?: TraitRankingCondition[]; // GENOME용: 형질별 가중치
|
criteriaType: RankingCriteriaType; // 랭킹 기준 타입
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => TraitRankingConditionDto)
|
||||||
|
traitConditions?: TraitRankingConditionDto[]; // GENOME용: 형질별 가중치
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 필터 옵션 DTO (FilterEngine용)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 엔진 옵션 DTO
|
||||||
|
* - 개체 목록 필터링에 사용
|
||||||
|
*/
|
||||||
|
export class FilterEngineOptionsDto implements FilterEngineOptions {
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
filters?: FilterCondition[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
sorts?: SortOption[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
pagination?: PaginationOption;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 메인 요청 DTO
|
// 메인 요청 DTO
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -123,7 +125,13 @@ export interface RankingOptions {
|
|||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export interface RankingRequestDto {
|
export class RankingRequestDto {
|
||||||
filterOptions?: FilterEngineOptions; // 필터/정렬/페이지네이션
|
@IsOptional()
|
||||||
rankingOptions: RankingOptions; // 랭킹 조건
|
@ValidateNested()
|
||||||
|
@Type(() => FilterEngineOptionsDto)
|
||||||
|
filterOptions?: FilterEngineOptionsDto; // 필터/정렬/페이지네이션
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => RankingOptionsDto)
|
||||||
|
rankingOptions: RankingOptionsDto; // 랭킹 조건
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
|
||||||
import { DashboardService } from './dashboard.service';
|
|
||||||
import { DashboardFilterDto } from './dto/dashboard-filter.dto';
|
|
||||||
|
|
||||||
@Controller('dashboard')
|
|
||||||
export class DashboardController {
|
|
||||||
constructor(private readonly dashboardService: DashboardService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /dashboard/summary/:farmNo - 농장 현황 요약
|
|
||||||
*/
|
|
||||||
@Get('summary/:farmNo')
|
|
||||||
getFarmSummary(
|
|
||||||
@Param('farmNo') farmNo: string,
|
|
||||||
@Query() filter: DashboardFilterDto,
|
|
||||||
) {
|
|
||||||
return this.dashboardService.getFarmSummary(+farmNo, filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /dashboard/analysis-completion/:farmNo - 분석 완료 현황
|
|
||||||
*/
|
|
||||||
@Get('analysis-completion/:farmNo')
|
|
||||||
getAnalysisCompletion(
|
|
||||||
@Param('farmNo') farmNo: string,
|
|
||||||
@Query() filter: DashboardFilterDto,
|
|
||||||
) {
|
|
||||||
return this.dashboardService.getAnalysisCompletion(+farmNo, filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /dashboard/evaluation/:farmNo - 농장 종합 평가
|
|
||||||
*/
|
|
||||||
@Get('evaluation/:farmNo')
|
|
||||||
getFarmEvaluation(
|
|
||||||
@Param('farmNo') farmNo: string,
|
|
||||||
@Query() filter: DashboardFilterDto,
|
|
||||||
) {
|
|
||||||
return this.dashboardService.getFarmEvaluation(+farmNo, filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /dashboard/region-comparison/:farmNo - 보은군 비교 분석
|
|
||||||
*/
|
|
||||||
@Get('region-comparison/:farmNo')
|
|
||||||
getRegionComparison(
|
|
||||||
@Param('farmNo') farmNo: string,
|
|
||||||
@Query() filter: DashboardFilterDto,
|
|
||||||
) {
|
|
||||||
return this.dashboardService.getRegionComparison(+farmNo, filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /dashboard/cow-distribution/:farmNo - 개체 분포 분석
|
|
||||||
*/
|
|
||||||
@Get('cow-distribution/:farmNo')
|
|
||||||
getCowDistribution(
|
|
||||||
@Param('farmNo') farmNo: string,
|
|
||||||
@Query() filter: DashboardFilterDto,
|
|
||||||
) {
|
|
||||||
return this.dashboardService.getCowDistribution(+farmNo, filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /dashboard/kpn-aggregation/:farmNo - KPN 추천 집계
|
|
||||||
*/
|
|
||||||
@Get('kpn-aggregation/:farmNo')
|
|
||||||
getKpnRecommendationAggregation(
|
|
||||||
@Param('farmNo') farmNo: string,
|
|
||||||
@Query() filter: DashboardFilterDto,
|
|
||||||
) {
|
|
||||||
return this.dashboardService.getKpnRecommendationAggregation(+farmNo, filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /dashboard/farm-kpn-inventory/:farmNo - 농장 보유 KPN 목록
|
|
||||||
*/
|
|
||||||
@Get('farm-kpn-inventory/:farmNo')
|
|
||||||
getFarmKpnInventory(@Param('farmNo') farmNo: string) {
|
|
||||||
return this.dashboardService.getFarmKpnInventory(+farmNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /dashboard/analysis-years/:farmNo - 농장 분석 이력 연도 목록
|
|
||||||
*/
|
|
||||||
@Get('analysis-years/:farmNo')
|
|
||||||
getAnalysisYears(@Param('farmNo') farmNo: string) {
|
|
||||||
return this.dashboardService.getAnalysisYears(+farmNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /dashboard/analysis-years/:farmNo/latest - 최신 분석 연도
|
|
||||||
*/
|
|
||||||
@Get('analysis-years/:farmNo/latest')
|
|
||||||
getLatestAnalysisYear(@Param('farmNo') farmNo: string) {
|
|
||||||
return this.dashboardService.getLatestAnalysisYear(+farmNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /dashboard/year-comparison/:farmNo - 3개년 비교 분석
|
|
||||||
*/
|
|
||||||
@Get('year-comparison/:farmNo')
|
|
||||||
getYearComparison(@Param('farmNo') farmNo: string) {
|
|
||||||
return this.dashboardService.getYearComparison(+farmNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /dashboard/repro-efficiency/:farmNo - 번식 효율성 분석
|
|
||||||
*/
|
|
||||||
@Get('repro-efficiency/:farmNo')
|
|
||||||
getReproEfficiency(
|
|
||||||
@Param('farmNo') farmNo: string,
|
|
||||||
@Query() filter: DashboardFilterDto,
|
|
||||||
) {
|
|
||||||
return this.dashboardService.getReproEfficiency(+farmNo, filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /dashboard/excellent-cows/:farmNo - 우수개체 추천
|
|
||||||
*/
|
|
||||||
@Get('excellent-cows/:farmNo')
|
|
||||||
getExcellentCows(
|
|
||||||
@Param('farmNo') farmNo: string,
|
|
||||||
@Query() filter: DashboardFilterDto,
|
|
||||||
) {
|
|
||||||
return this.dashboardService.getExcellentCows(+farmNo, filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /dashboard/cull-cows/:farmNo - 도태개체 추천
|
|
||||||
*/
|
|
||||||
@Get('cull-cows/:farmNo')
|
|
||||||
getCullCows(
|
|
||||||
@Param('farmNo') farmNo: string,
|
|
||||||
@Query() filter: DashboardFilterDto,
|
|
||||||
) {
|
|
||||||
return this.dashboardService.getCullCows(+farmNo, filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /dashboard/cattle-ranking/:farmNo - 보은군 내 소 개별 순위
|
|
||||||
*/
|
|
||||||
@Get('cattle-ranking/:farmNo')
|
|
||||||
getCattleRankingInRegion(
|
|
||||||
@Param('farmNo') farmNo: string,
|
|
||||||
@Query() filter: DashboardFilterDto,
|
|
||||||
) {
|
|
||||||
return this.dashboardService.getCattleRankingInRegion(+farmNo, filter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { DashboardController } from './dashboard.controller';
|
|
||||||
import { DashboardService } from './dashboard.service';
|
|
||||||
import { CowModel } from '../cow/entities/cow.entity';
|
|
||||||
import { FarmModel } from '../farm/entities/farm.entity';
|
|
||||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
|
||||||
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
TypeOrmModule.forFeature([
|
|
||||||
CowModel,
|
|
||||||
FarmModel,
|
|
||||||
GenomeRequestModel,
|
|
||||||
GenomeTraitDetailModel,
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
controllers: [DashboardController],
|
|
||||||
providers: [DashboardService],
|
|
||||||
exports: [DashboardService],
|
|
||||||
})
|
|
||||||
export class DashboardModule {}
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository, IsNull } from 'typeorm';
|
|
||||||
import { CowModel } from '../cow/entities/cow.entity';
|
|
||||||
import { FarmModel } from '../farm/entities/farm.entity';
|
|
||||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
|
||||||
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
|
||||||
import { DashboardFilterDto } from './dto/dashboard-filter.dto';
|
|
||||||
import { isValidGenomeAnalysis, VALID_CHIP_SIRE_NAME } from '../common/config/GenomeAnalysisConfig';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class DashboardService {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(CowModel)
|
|
||||||
private readonly cowRepository: Repository<CowModel>,
|
|
||||||
|
|
||||||
@InjectRepository(FarmModel)
|
|
||||||
private readonly farmRepository: Repository<FarmModel>,
|
|
||||||
|
|
||||||
@InjectRepository(GenomeRequestModel)
|
|
||||||
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
|
||||||
|
|
||||||
@InjectRepository(GenomeTraitDetailModel)
|
|
||||||
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 농장 현황 요약
|
|
||||||
*/
|
|
||||||
async getFarmSummary(farmNo: number, filter?: DashboardFilterDto) {
|
|
||||||
// 농장 정보 조회
|
|
||||||
const farm = await this.farmRepository.findOne({
|
|
||||||
where: { pkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 농장 소 목록 조회
|
|
||||||
const cows = await this.cowRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalCowCount = cows.length;
|
|
||||||
const maleCowCount = cows.filter(cow => cow.cowSex === 'M').length;
|
|
||||||
const femaleCowCount = cows.filter(cow => cow.cowSex === 'F').length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
farmNo,
|
|
||||||
farmName: farm?.farmerName || '농장',
|
|
||||||
totalCowCount,
|
|
||||||
maleCowCount,
|
|
||||||
femaleCowCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 분석 완료 현황
|
|
||||||
*/
|
|
||||||
async getAnalysisCompletion(farmNo: number, filter?: DashboardFilterDto) {
|
|
||||||
// 농장의 모든 유전체 분석 의뢰 조회
|
|
||||||
const requests = await this.genomeRequestRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
relations: ['cow'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const farmAnlysCnt = requests.length;
|
|
||||||
const matchCnt = requests.filter(r => r.chipSireName === '일치').length;
|
|
||||||
const failCnt = requests.filter(r => r.chipSireName && r.chipSireName !== '일치').length;
|
|
||||||
const noHistCnt = requests.filter(r => !r.chipSireName).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
farmAnlysCnt,
|
|
||||||
matchCnt,
|
|
||||||
failCnt,
|
|
||||||
noHistCnt,
|
|
||||||
paternities: requests.map(r => ({
|
|
||||||
cowNo: r.fkCowNo,
|
|
||||||
cowId: r.cow?.cowId,
|
|
||||||
fatherMatch: r.chipSireName === '일치' ? '일치' : (r.chipSireName ? '불일치' : '미확인'),
|
|
||||||
requestDt: r.requestDt,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 농장 종합 평가
|
|
||||||
*/
|
|
||||||
async getFarmEvaluation(farmNo: number, filter?: DashboardFilterDto) {
|
|
||||||
const cows = await this.cowRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 각 개체의 유전체 점수 계산
|
|
||||||
const scores: number[] = [];
|
|
||||||
|
|
||||||
for (const cow of cows) {
|
|
||||||
// cowId로 직접 형질 데이터 조회
|
|
||||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
|
||||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (traitDetails.length === 0) continue;
|
|
||||||
|
|
||||||
// 모든 형질의 EBV 평균 계산
|
|
||||||
const ebvValues = traitDetails
|
|
||||||
.filter(d => d.traitEbv !== null)
|
|
||||||
.map(d => Number(d.traitEbv));
|
|
||||||
|
|
||||||
if (ebvValues.length > 0) {
|
|
||||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
|
||||||
scores.push(avgEbv);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const farmAverage = scores.length > 0
|
|
||||||
? scores.reduce((sum, s) => sum + s, 0) / scores.length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// 등급 산정 (표준화육종가 기준)
|
|
||||||
let grade = 'C';
|
|
||||||
if (farmAverage >= 1.0) grade = 'A';
|
|
||||||
else if (farmAverage >= 0.5) grade = 'B';
|
|
||||||
else if (farmAverage >= -0.5) grade = 'C';
|
|
||||||
else if (farmAverage >= -1.0) grade = 'D';
|
|
||||||
else grade = 'E';
|
|
||||||
|
|
||||||
return {
|
|
||||||
farmNo,
|
|
||||||
farmAverage: Math.round(farmAverage * 100) / 100,
|
|
||||||
grade,
|
|
||||||
analyzedCount: scores.length,
|
|
||||||
totalCount: cows.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 보은군 비교 분석
|
|
||||||
*/
|
|
||||||
async getRegionComparison(farmNo: number, filter?: DashboardFilterDto) {
|
|
||||||
// 내 농장 평균 계산
|
|
||||||
const farmEval = await this.getFarmEvaluation(farmNo, filter);
|
|
||||||
|
|
||||||
// 전체 농장 평균 계산 (보은군 대비)
|
|
||||||
const allFarms = await this.farmRepository.find({
|
|
||||||
where: { delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
const farmScores: { farmNo: number; avgScore: number }[] = [];
|
|
||||||
|
|
||||||
for (const farm of allFarms) {
|
|
||||||
const farmCows = await this.cowRepository.find({
|
|
||||||
where: { fkFarmNo: farm.pkFarmNo, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
const scores: number[] = [];
|
|
||||||
for (const cow of farmCows) {
|
|
||||||
// cowId로 직접 형질 데이터 조회
|
|
||||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
|
||||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (traitDetails.length === 0) continue;
|
|
||||||
|
|
||||||
const ebvValues = traitDetails
|
|
||||||
.filter(d => d.traitEbv !== null)
|
|
||||||
.map(d => Number(d.traitEbv));
|
|
||||||
|
|
||||||
if (ebvValues.length > 0) {
|
|
||||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
|
||||||
scores.push(avgEbv);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scores.length > 0) {
|
|
||||||
farmScores.push({
|
|
||||||
farmNo: farm.pkFarmNo,
|
|
||||||
avgScore: scores.reduce((sum, s) => sum + s, 0) / scores.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 내 농장 순위 계산
|
|
||||||
farmScores.sort((a, b) => b.avgScore - a.avgScore);
|
|
||||||
const myFarmRank = farmScores.findIndex(f => f.farmNo === farmNo) + 1;
|
|
||||||
const totalFarmCount = farmScores.length;
|
|
||||||
const topPercent = totalFarmCount > 0 ? Math.round((myFarmRank / totalFarmCount) * 100) : 0;
|
|
||||||
|
|
||||||
// 지역 평균
|
|
||||||
const regionAverage = farmScores.length > 0
|
|
||||||
? farmScores.reduce((sum, f) => sum + f.avgScore, 0) / farmScores.length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
farmNo,
|
|
||||||
farmAverage: farmEval.farmAverage,
|
|
||||||
regionAverage: Math.round(regionAverage * 100) / 100,
|
|
||||||
farmRank: myFarmRank || 1,
|
|
||||||
totalFarmCount: totalFarmCount || 1,
|
|
||||||
topPercent: topPercent || 100,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 개체 분포 분석
|
|
||||||
*/
|
|
||||||
async getCowDistribution(farmNo: number, filter?: DashboardFilterDto) {
|
|
||||||
const cows = await this.cowRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
const distribution = {
|
|
||||||
A: 0,
|
|
||||||
B: 0,
|
|
||||||
C: 0,
|
|
||||||
D: 0,
|
|
||||||
E: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const cow of cows) {
|
|
||||||
// cowId로 직접 형질 데이터 조회
|
|
||||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
|
||||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (traitDetails.length === 0) continue;
|
|
||||||
|
|
||||||
const ebvValues = traitDetails
|
|
||||||
.filter(d => d.traitEbv !== null)
|
|
||||||
.map(d => Number(d.traitEbv));
|
|
||||||
|
|
||||||
if (ebvValues.length > 0) {
|
|
||||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
|
||||||
|
|
||||||
if (avgEbv >= 1.0) distribution.A++;
|
|
||||||
else if (avgEbv >= 0.5) distribution.B++;
|
|
||||||
else if (avgEbv >= -0.5) distribution.C++;
|
|
||||||
else if (avgEbv >= -1.0) distribution.D++;
|
|
||||||
else distribution.E++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
farmNo,
|
|
||||||
distribution,
|
|
||||||
total: cows.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* KPN 추천 집계
|
|
||||||
*/
|
|
||||||
async getKpnRecommendationAggregation(farmNo: number, filter?: DashboardFilterDto) {
|
|
||||||
// 타겟 유전자 기반 KPN 추천 로직
|
|
||||||
const targetGenes = filter?.targetGenes || [];
|
|
||||||
|
|
||||||
// 농장 소 목록 조회
|
|
||||||
const cows = await this.cowRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 간단한 KPN 추천 집계 (실제 로직은 더 복잡할 수 있음)
|
|
||||||
const kpnAggregations = [
|
|
||||||
{
|
|
||||||
kpnNumber: 'KPN001',
|
|
||||||
kpnName: '한우왕',
|
|
||||||
avgMatchingScore: 85.5,
|
|
||||||
recommendedCowCount: Math.floor(cows.length * 0.3),
|
|
||||||
percentage: 30,
|
|
||||||
rank: 1,
|
|
||||||
isOwned: false,
|
|
||||||
sampleCowIds: cows.slice(0, 3).map(c => c.cowId),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kpnNumber: 'KPN002',
|
|
||||||
kpnName: '육량대왕',
|
|
||||||
avgMatchingScore: 82.3,
|
|
||||||
recommendedCowCount: Math.floor(cows.length * 0.25),
|
|
||||||
percentage: 25,
|
|
||||||
rank: 2,
|
|
||||||
isOwned: true,
|
|
||||||
sampleCowIds: cows.slice(3, 6).map(c => c.cowId),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kpnNumber: 'KPN003',
|
|
||||||
kpnName: '품질명가',
|
|
||||||
avgMatchingScore: 79.1,
|
|
||||||
recommendedCowCount: Math.floor(cows.length * 0.2),
|
|
||||||
percentage: 20,
|
|
||||||
rank: 3,
|
|
||||||
isOwned: false,
|
|
||||||
sampleCowIds: cows.slice(6, 9).map(c => c.cowId),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
farmNo,
|
|
||||||
targetGenes,
|
|
||||||
kpnAggregations,
|
|
||||||
totalCows: cows.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 농장 보유 KPN 목록
|
|
||||||
*/
|
|
||||||
async getFarmKpnInventory(farmNo: number) {
|
|
||||||
// 실제 구현에서는 별도의 KPN 보유 테이블을 조회
|
|
||||||
return {
|
|
||||||
farmNo,
|
|
||||||
kpnList: [
|
|
||||||
{ kpnNumber: 'KPN002', kpnName: '육량대왕', stockCount: 10 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 분석 이력 연도 목록
|
|
||||||
*/
|
|
||||||
async getAnalysisYears(farmNo: number): Promise<number[]> {
|
|
||||||
const requests = await this.genomeRequestRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
select: ['requestDt'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const years = new Set<number>();
|
|
||||||
for (const req of requests) {
|
|
||||||
if (req.requestDt) {
|
|
||||||
years.add(new Date(req.requestDt).getFullYear());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(years).sort((a, b) => b - a);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최신 분석 연도
|
|
||||||
*/
|
|
||||||
async getLatestAnalysisYear(farmNo: number): Promise<number> {
|
|
||||||
const years = await this.getAnalysisYears(farmNo);
|
|
||||||
return years[0] || new Date().getFullYear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 3개년 비교 분석
|
|
||||||
*/
|
|
||||||
async getYearComparison(farmNo: number) {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const years = [currentYear, currentYear - 1, currentYear - 2];
|
|
||||||
|
|
||||||
const comparison = [];
|
|
||||||
for (const year of years) {
|
|
||||||
// 해당 연도의 분석 데이터 집계
|
|
||||||
const requests = await this.genomeRequestRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
const yearRequests = requests.filter(r => {
|
|
||||||
if (!r.requestDt) return false;
|
|
||||||
return new Date(r.requestDt).getFullYear() === year;
|
|
||||||
});
|
|
||||||
|
|
||||||
comparison.push({
|
|
||||||
year,
|
|
||||||
analysisCount: yearRequests.length,
|
|
||||||
matchCount: yearRequests.filter(r => r.chipSireName === '일치').length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { farmNo, comparison };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 번식 효율성 분석 (더미 데이터)
|
|
||||||
*/
|
|
||||||
async getReproEfficiency(farmNo: number, filter?: DashboardFilterDto) {
|
|
||||||
return {
|
|
||||||
farmNo,
|
|
||||||
avgCalvingInterval: 12.5,
|
|
||||||
avgFirstCalvingAge: 24,
|
|
||||||
conceptionRate: 65.5,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 우수개체 추천
|
|
||||||
*/
|
|
||||||
async getExcellentCows(farmNo: number, filter?: DashboardFilterDto) {
|
|
||||||
const limit = filter?.limit || 5;
|
|
||||||
|
|
||||||
const cows = await this.cowRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
const cowsWithScore: Array<{ cow: CowModel; score: number }> = [];
|
|
||||||
|
|
||||||
for (const cow of cows) {
|
|
||||||
// cowId로 직접 형질 데이터 조회
|
|
||||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
|
||||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (traitDetails.length === 0) continue;
|
|
||||||
|
|
||||||
const ebvValues = traitDetails
|
|
||||||
.filter(d => d.traitEbv !== null)
|
|
||||||
.map(d => Number(d.traitEbv));
|
|
||||||
|
|
||||||
if (ebvValues.length > 0) {
|
|
||||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
|
||||||
cowsWithScore.push({ cow, score: avgEbv });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 점수 내림차순 정렬
|
|
||||||
cowsWithScore.sort((a, b) => b.score - a.score);
|
|
||||||
|
|
||||||
return {
|
|
||||||
farmNo,
|
|
||||||
excellentCows: cowsWithScore.slice(0, limit).map((item, index) => ({
|
|
||||||
rank: index + 1,
|
|
||||||
cowNo: item.cow.pkCowNo,
|
|
||||||
cowId: item.cow.cowId,
|
|
||||||
score: Math.round(item.score * 100) / 100,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 도태개체 추천
|
|
||||||
*/
|
|
||||||
async getCullCows(farmNo: number, filter?: DashboardFilterDto) {
|
|
||||||
const limit = filter?.limit || 5;
|
|
||||||
|
|
||||||
const cows = await this.cowRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
const cowsWithScore: Array<{ cow: CowModel; score: number }> = [];
|
|
||||||
|
|
||||||
for (const cow of cows) {
|
|
||||||
// cowId로 직접 형질 데이터 조회
|
|
||||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
|
||||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (traitDetails.length === 0) continue;
|
|
||||||
|
|
||||||
const ebvValues = traitDetails
|
|
||||||
.filter(d => d.traitEbv !== null)
|
|
||||||
.map(d => Number(d.traitEbv));
|
|
||||||
|
|
||||||
if (ebvValues.length > 0) {
|
|
||||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
|
||||||
cowsWithScore.push({ cow, score: avgEbv });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 점수 오름차순 정렬 (낮은 점수가 도태 대상)
|
|
||||||
cowsWithScore.sort((a, b) => a.score - b.score);
|
|
||||||
|
|
||||||
return {
|
|
||||||
farmNo,
|
|
||||||
cullCows: cowsWithScore.slice(0, limit).map((item, index) => ({
|
|
||||||
rank: index + 1,
|
|
||||||
cowNo: item.cow.pkCowNo,
|
|
||||||
cowId: item.cow.cowId,
|
|
||||||
score: Math.round(item.score * 100) / 100,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 보은군 내 소 개별 순위
|
|
||||||
*/
|
|
||||||
async getCattleRankingInRegion(farmNo: number, filter?: DashboardFilterDto) {
|
|
||||||
// 전체 소 목록과 점수 계산
|
|
||||||
const allCows = await this.cowRepository.find({
|
|
||||||
where: { delDt: IsNull() },
|
|
||||||
relations: ['farm'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const cowsWithScore: Array<{
|
|
||||||
cow: CowModel;
|
|
||||||
score: number;
|
|
||||||
farmNo: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const cow of allCows) {
|
|
||||||
// cowId로 직접 형질 데이터 조회
|
|
||||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
|
||||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (traitDetails.length === 0) continue;
|
|
||||||
|
|
||||||
const ebvValues = traitDetails
|
|
||||||
.filter(d => d.traitEbv !== null)
|
|
||||||
.map(d => Number(d.traitEbv));
|
|
||||||
|
|
||||||
if (ebvValues.length > 0) {
|
|
||||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
|
||||||
cowsWithScore.push({
|
|
||||||
cow,
|
|
||||||
score: avgEbv,
|
|
||||||
farmNo: cow.fkFarmNo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 점수 내림차순 정렬
|
|
||||||
cowsWithScore.sort((a, b) => b.score - a.score);
|
|
||||||
|
|
||||||
// 순위 부여
|
|
||||||
const rankedCows = cowsWithScore.map((item, index) => ({
|
|
||||||
...item,
|
|
||||||
rank: index + 1,
|
|
||||||
percentile: Math.round(((index + 1) / cowsWithScore.length) * 100),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 내 농장 소만 필터링
|
|
||||||
const myFarmCows = rankedCows.filter(item => item.farmNo === farmNo);
|
|
||||||
|
|
||||||
const farm = await this.farmRepository.findOne({
|
|
||||||
where: { pkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
farmNo,
|
|
||||||
farmName: farm?.farmerName || '농장',
|
|
||||||
regionName: farm?.regionSi || '보은군',
|
|
||||||
totalCattle: cowsWithScore.length,
|
|
||||||
farmCattleCount: myFarmCows.length,
|
|
||||||
rankings: myFarmCows.map(item => ({
|
|
||||||
cowNo: item.cow.cowId,
|
|
||||||
cowName: `KOR ${item.cow.cowId}`,
|
|
||||||
genomeScore: Math.round(item.score * 100) / 100,
|
|
||||||
rank: item.rank,
|
|
||||||
totalCattle: cowsWithScore.length,
|
|
||||||
percentile: item.percentile,
|
|
||||||
})),
|
|
||||||
statistics: {
|
|
||||||
bestRank: myFarmCows.length > 0 ? myFarmCows[0].rank : 0,
|
|
||||||
averageRank: myFarmCows.length > 0
|
|
||||||
? Math.round(myFarmCows.reduce((sum, c) => sum + c.rank, 0) / myFarmCows.length)
|
|
||||||
: 0,
|
|
||||||
topPercentCount: myFarmCows.filter(c => c.percentile <= 10).length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { IsOptional, IsArray, IsNumber, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 필터 DTO
|
|
||||||
*/
|
|
||||||
export class DashboardFilterDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
anlysStatus?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
reproType?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
geneGrades?: string[];
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
genomeGrades?: string[];
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
reproGrades?: string[];
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
targetGenes?: string[];
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
minScore?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
limit?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
regionNm?: string;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
import { FarmService } from './farm.service';
|
import { FarmService } from './farm.service';
|
||||||
import { FarmModel } from './entities/farm.entity';
|
|
||||||
|
|
||||||
@Controller('farm')
|
@Controller('farm')
|
||||||
export class FarmController {
|
export class FarmController {
|
||||||
@@ -13,40 +12,4 @@ export class FarmController {
|
|||||||
}
|
}
|
||||||
return this.farmService.findAll();
|
return this.farmService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
|
||||||
findOne(@Param('id') id: string) {
|
|
||||||
return this.farmService.findOne(+id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /farm/:farmNo/analysis-latest - 농장 최신 분석 의뢰 정보 조회
|
|
||||||
*/
|
|
||||||
@Get(':farmNo/analysis-latest')
|
|
||||||
getLatestAnalysisRequest(@Param('farmNo') farmNo: string) {
|
|
||||||
return this.farmService.getLatestAnalysisRequest(+farmNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /farm/:farmNo/analysis-all - 농장 전체 분석 의뢰 목록 조회
|
|
||||||
*/
|
|
||||||
@Get(':farmNo/analysis-all')
|
|
||||||
getAllAnalysisRequests(@Param('farmNo') farmNo: string) {
|
|
||||||
return this.farmService.getAllAnalysisRequests(+farmNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
create(@Body() data: Partial<FarmModel>) {
|
|
||||||
return this.farmService.create(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put(':id')
|
|
||||||
update(@Param('id') id: string, @Body() data: Partial<FarmModel>) {
|
|
||||||
return this.farmService.update(+id, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
remove(@Param('id') id: string) {
|
|
||||||
return this.farmService.remove(+id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,9 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { FarmController } from './farm.controller';
|
import { FarmController } from './farm.controller';
|
||||||
import { FarmService } from './farm.service';
|
import { FarmService } from './farm.service';
|
||||||
import { FarmModel } from './entities/farm.entity';
|
import { FarmModel } from './entities/farm.entity';
|
||||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
|
||||||
import { CowModel } from '../cow/entities/cow.entity';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [TypeOrmModule.forFeature([FarmModel])],
|
||||||
TypeOrmModule.forFeature([
|
|
||||||
FarmModel,
|
|
||||||
GenomeRequestModel,
|
|
||||||
CowModel,
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
controllers: [FarmController],
|
controllers: [FarmController],
|
||||||
providers: [FarmService],
|
providers: [FarmService],
|
||||||
exports: [FarmService],
|
exports: [FarmService],
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, IsNull } from 'typeorm';
|
import { Repository, IsNull } from 'typeorm';
|
||||||
import { FarmModel } from './entities/farm.entity';
|
import { FarmModel } from './entities/farm.entity';
|
||||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
|
||||||
import { CowModel } from '../cow/entities/cow.entity';
|
|
||||||
import { isValidGenomeAnalysis, VALID_CHIP_SIRE_NAME } from '../common/config/GenomeAnalysisConfig';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FarmService {
|
export class FarmService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(FarmModel)
|
@InjectRepository(FarmModel)
|
||||||
private readonly farmRepository: Repository<FarmModel>,
|
private readonly farmRepository: Repository<FarmModel>,
|
||||||
|
|
||||||
@InjectRepository(GenomeRequestModel)
|
|
||||||
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
|
||||||
|
|
||||||
@InjectRepository(CowModel)
|
|
||||||
private readonly cowRepository: Repository<CowModel>,
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// 전체 농장 조회
|
// 전체 농장 조회
|
||||||
@@ -36,93 +27,4 @@ export class FarmService {
|
|||||||
order: { regDt: 'DESC' },
|
order: { regDt: 'DESC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 농장 단건 조회
|
|
||||||
async findOne(id: number): Promise<FarmModel> {
|
|
||||||
const farm = await this.farmRepository.findOne({
|
|
||||||
where: { pkFarmNo: id, delDt: IsNull() },
|
|
||||||
relations: ['user'],
|
|
||||||
});
|
|
||||||
if (!farm) {
|
|
||||||
throw new NotFoundException('Farm #' + id + ' not found');
|
|
||||||
}
|
|
||||||
return farm;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 농장 생성
|
|
||||||
async create(data: Partial<FarmModel>): Promise<FarmModel> {
|
|
||||||
const farm = this.farmRepository.create(data);
|
|
||||||
return this.farmRepository.save(farm);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 농장 수정
|
|
||||||
async update(id: number, data: Partial<FarmModel>): Promise<FarmModel> {
|
|
||||||
await this.findOne(id);
|
|
||||||
await this.farmRepository.update(id, data);
|
|
||||||
return this.findOne(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 농장 삭제
|
|
||||||
async remove(id: number): Promise<void> {
|
|
||||||
const farm = await this.findOne(id);
|
|
||||||
await this.farmRepository.softRemove(farm);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 농장 최신 분석 의뢰 정보 조회
|
|
||||||
async getLatestAnalysisRequest(farmNo: number): Promise<any> {
|
|
||||||
const farm = await this.findOne(farmNo);
|
|
||||||
|
|
||||||
const requests = await this.genomeRequestRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
relations: ['cow'],
|
|
||||||
order: { requestDt: 'DESC' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const cows = await this.cowRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
const farmAnlysCnt = requests.length;
|
|
||||||
const matchCnt = requests.filter(r => isValidGenomeAnalysis(r.chipSireName, r.chipDamName, r.cow?.cowId)).length;
|
|
||||||
const failCnt = requests.filter(r => r.chipSireName && r.chipSireName !== '일치').length;
|
|
||||||
const noHistCnt = requests.filter(r => !r.chipSireName).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
pkFarmAnlysNo: 1,
|
|
||||||
fkFarmNo: farmNo,
|
|
||||||
farmAnlysNm: farm.farmerName,
|
|
||||||
anlysReqDt: requests[0]?.requestDt || new Date(),
|
|
||||||
region: farm.regionSi,
|
|
||||||
city: farm.regionGu,
|
|
||||||
anlysReqCnt: cows.length,
|
|
||||||
farmAnlysCnt,
|
|
||||||
matchCnt,
|
|
||||||
mismatchCnt: failCnt,
|
|
||||||
failCnt,
|
|
||||||
noHistCnt,
|
|
||||||
matchRate: farmAnlysCnt > 0 ? Math.round((matchCnt / farmAnlysCnt) * 100) : 0,
|
|
||||||
msAnlysCnt: 0,
|
|
||||||
anlysRmrk: '',
|
|
||||||
paternities: requests.map(r => ({
|
|
||||||
pkFarmPaternityNo: r.pkRequestNo,
|
|
||||||
fkFarmAnlysNo: 1,
|
|
||||||
receiptDate: r.requestDt,
|
|
||||||
farmOwnerName: farm.farmerName,
|
|
||||||
individualNo: r.cow?.cowId || '',
|
|
||||||
kpnNo: r.cow?.sireKpn || '',
|
|
||||||
motherIndividualNo: r.cow?.damCowId || '',
|
|
||||||
hairRootQuality: r.sampleAmount || '',
|
|
||||||
remarks: r.cowRemarks || '',
|
|
||||||
fatherMatch: r.chipSireName === '일치' ? '일치' : (r.chipSireName ? '불일치' : '미확인'),
|
|
||||||
motherMatch: r.chipDamName || '미확인',
|
|
||||||
reportDate: r.chipReportDt,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 농장 전체 분석 의뢰 목록 조회
|
|
||||||
async getAllAnalysisRequests(farmNo: number): Promise<any[]> {
|
|
||||||
const latestRequest = await this.getLatestAnalysisRequest(farmNo);
|
|
||||||
return [latestRequest];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Param, Post, Body } from '@nestjs/common';
|
import { Controller, Get, Param } from '@nestjs/common';
|
||||||
import { GeneService } from './gene.service';
|
import { GeneService } from './gene.service';
|
||||||
import { GeneDetailModel } from './entities/gene-detail.entity';
|
import { GeneDetailModel } from './entities/gene-detail.entity';
|
||||||
|
|
||||||
@@ -14,53 +14,4 @@ export class GeneController {
|
|||||||
async findByCowId(@Param('cowId') cowId: string): Promise<GeneDetailModel[]> {
|
async findByCowId(@Param('cowId') cowId: string): Promise<GeneDetailModel[]> {
|
||||||
return this.geneService.findByCowId(cowId);
|
return this.geneService.findByCowId(cowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 개체별 유전자 요약 정보 조회
|
|
||||||
* GET /gene/summary/:cowId
|
|
||||||
*/
|
|
||||||
@Get('summary/:cowId')
|
|
||||||
async getGeneSummary(@Param('cowId') cowId: string): Promise<{
|
|
||||||
total: number;
|
|
||||||
homozygousCount: number;
|
|
||||||
heterozygousCount: number;
|
|
||||||
}> {
|
|
||||||
return this.geneService.getGeneSummary(cowId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 의뢰번호로 유전자 상세 정보 조회
|
|
||||||
* GET /gene/request/:requestNo
|
|
||||||
*/
|
|
||||||
@Get('request/:requestNo')
|
|
||||||
async findByRequestNo(@Param('requestNo') requestNo: number): Promise<GeneDetailModel[]> {
|
|
||||||
return this.geneService.findByRequestNo(requestNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 유전자 상세 정보 단건 조회
|
|
||||||
* GET /gene/detail/:geneDetailNo
|
|
||||||
*/
|
|
||||||
@Get('detail/:geneDetailNo')
|
|
||||||
async findOne(@Param('geneDetailNo') geneDetailNo: number): Promise<GeneDetailModel> {
|
|
||||||
return this.geneService.findOne(geneDetailNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 유전자 상세 정보 생성
|
|
||||||
* POST /gene
|
|
||||||
*/
|
|
||||||
@Post()
|
|
||||||
async create(@Body() data: Partial<GeneDetailModel>): Promise<GeneDetailModel> {
|
|
||||||
return this.geneService.create(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 유전자 상세 정보 일괄 생성
|
|
||||||
* POST /gene/bulk
|
|
||||||
*/
|
|
||||||
@Post('bulk')
|
|
||||||
async createBulk(@Body() dataList: Partial<GeneDetailModel>[]): Promise<GeneDetailModel[]> {
|
|
||||||
return this.geneService.createBulk(dataList);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { IsNull, Repository } from 'typeorm';
|
import { IsNull, Repository } from 'typeorm';
|
||||||
import { GeneDetailModel } from './entities/gene-detail.entity';
|
import { GeneDetailModel } from './entities/gene-detail.entity';
|
||||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GeneService {
|
export class GeneService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(GeneDetailModel)
|
@InjectRepository(GeneDetailModel)
|
||||||
private readonly geneDetailRepository: Repository<GeneDetailModel>,
|
private readonly geneDetailRepository: Repository<GeneDetailModel>,
|
||||||
@InjectRepository(GenomeRequestModel)
|
|
||||||
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +16,7 @@ export class GeneService {
|
|||||||
* @returns 유전자 상세 정보 배열
|
* @returns 유전자 상세 정보 배열
|
||||||
*/
|
*/
|
||||||
async findByCowId(cowId: string): Promise<GeneDetailModel[]> {
|
async findByCowId(cowId: string): Promise<GeneDetailModel[]> {
|
||||||
const results = await this.geneDetailRepository.find({
|
return await this.geneDetailRepository.find({
|
||||||
where: {
|
where: {
|
||||||
cowId,
|
cowId,
|
||||||
delDt: IsNull(),
|
delDt: IsNull(),
|
||||||
@@ -29,100 +26,5 @@ export class GeneService {
|
|||||||
position: 'ASC',
|
position: 'ASC',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 의뢰번호(requestNo)로 유전자 상세 정보 조회
|
|
||||||
* @param requestNo 의뢰번호
|
|
||||||
* @returns 유전자 상세 정보 배열
|
|
||||||
*/
|
|
||||||
async findByRequestNo(requestNo: number): Promise<GeneDetailModel[]> {
|
|
||||||
const results = await this.geneDetailRepository.find({
|
|
||||||
where: {
|
|
||||||
fkRequestNo: requestNo,
|
|
||||||
delDt: IsNull(),
|
|
||||||
},
|
|
||||||
order: {
|
|
||||||
chromosome: 'ASC',
|
|
||||||
position: 'ASC',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 개체별 유전자 요약 정보 조회
|
|
||||||
* @param cowId 개체식별번호
|
|
||||||
* @returns 동형접합/이형접합 개수 요약
|
|
||||||
*/
|
|
||||||
async getGeneSummary(cowId: string): Promise<{
|
|
||||||
total: number;
|
|
||||||
homozygousCount: number;
|
|
||||||
heterozygousCount: number;
|
|
||||||
}> {
|
|
||||||
const geneDetails = await this.findByCowId(cowId);
|
|
||||||
|
|
||||||
let homozygousCount = 0;
|
|
||||||
let heterozygousCount = 0;
|
|
||||||
|
|
||||||
geneDetails.forEach((gene) => {
|
|
||||||
if (gene.allele1 && gene.allele2) {
|
|
||||||
if (gene.allele1 === gene.allele2) {
|
|
||||||
homozygousCount++;
|
|
||||||
} else {
|
|
||||||
heterozygousCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: geneDetails.length,
|
|
||||||
homozygousCount,
|
|
||||||
heterozygousCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 유전자 상세 정보 단건 조회
|
|
||||||
* @param geneDetailNo 유전자상세번호
|
|
||||||
* @returns 유전자 상세 정보
|
|
||||||
*/
|
|
||||||
async findOne(geneDetailNo: number): Promise<GeneDetailModel> {
|
|
||||||
const result = await this.geneDetailRepository.findOne({
|
|
||||||
where: {
|
|
||||||
pkGeneDetailNo: geneDetailNo,
|
|
||||||
delDt: IsNull(),
|
|
||||||
},
|
|
||||||
relations: ['genomeRequest'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
throw new NotFoundException(`유전자 상세 정보를 찾을 수 없습니다. (geneDetailNo: ${geneDetailNo})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 유전자 상세 정보 생성
|
|
||||||
* @param data 생성할 데이터
|
|
||||||
* @returns 생성된 유전자 상세 정보
|
|
||||||
*/
|
|
||||||
async create(data: Partial<GeneDetailModel>): Promise<GeneDetailModel> {
|
|
||||||
const geneDetail = this.geneDetailRepository.create(data);
|
|
||||||
return await this.geneDetailRepository.save(geneDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 유전자 상세 정보 일괄 생성
|
|
||||||
* @param dataList 생성할 데이터 배열
|
|
||||||
* @returns 생성된 유전자 상세 정보 배열
|
|
||||||
*/
|
|
||||||
async createBulk(dataList: Partial<GeneDetailModel>[]): Promise<GeneDetailModel[]> {
|
|
||||||
const geneDetails = this.geneDetailRepository.create(dataList);
|
|
||||||
return await this.geneDetailRepository.save(geneDetails);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
|
||||||
import { Public } from '../common/decorators/public.decorator';
|
|
||||||
import { GenomeService } from './genome.service';
|
import { GenomeService } from './genome.service';
|
||||||
import { GenomeRequestModel } from './entities/genome-request.entity';
|
|
||||||
import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity';
|
|
||||||
|
|
||||||
export interface CategoryAverageDto {
|
export interface CategoryAverageDto {
|
||||||
category: string;
|
category: string;
|
||||||
@@ -30,16 +27,6 @@ export class GenomeController {
|
|||||||
return this.genomeService.getDashboardStats(+farmNo);
|
return this.genomeService.getDashboardStats(+farmNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /genome/farm-trait-comparison/:farmNo
|
|
||||||
* 농가별 형질 비교 데이터 (농가 vs 지역 vs 전국)
|
|
||||||
* @param farmNo - 농장 번호
|
|
||||||
*/
|
|
||||||
@Get('farm-trait-comparison/:farmNo')
|
|
||||||
getFarmTraitComparison(@Param('farmNo') farmNo: string) {
|
|
||||||
return this.genomeService.getFarmTraitComparison(+farmNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /genome/farm-region-ranking/:farmNo
|
* GET /genome/farm-region-ranking/:farmNo
|
||||||
* 농가의 보은군 내 순위 조회 (대시보드용)
|
* 농가의 보은군 내 순위 조회 (대시보드용)
|
||||||
@@ -67,21 +54,6 @@ export class GenomeController {
|
|||||||
return this.genomeService.getTraitRank(cowId, traitName);
|
return this.genomeService.getTraitRank(cowId, traitName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Genome Request endpoints
|
|
||||||
@Get('request')
|
|
||||||
findAllRequests(
|
|
||||||
@Query('cowId') cowId?: string,
|
|
||||||
@Query('farmId') farmId?: string,
|
|
||||||
) {
|
|
||||||
if (cowId) {
|
|
||||||
return this.genomeService.findRequestsByCowId(+cowId);
|
|
||||||
}
|
|
||||||
if (farmId) {
|
|
||||||
return this.genomeService.findRequestsByFarmId(+farmId);
|
|
||||||
}
|
|
||||||
return this.genomeService.findAllRequests();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /genome/request/:cowId
|
* GET /genome/request/:cowId
|
||||||
* 개체식별번호(KOR...)로 유전체 분석 의뢰 정보 조회
|
* 개체식별번호(KOR...)로 유전체 분석 의뢰 정보 조회
|
||||||
@@ -92,11 +64,6 @@ export class GenomeController {
|
|||||||
return this.genomeService.findRequestByCowIdentifier(cowId);
|
return this.genomeService.findRequestByCowIdentifier(cowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('request')
|
|
||||||
createRequest(@Body() data: Partial<GenomeRequestModel>) {
|
|
||||||
return this.genomeService.createRequest(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /genome/comparison-averages/:cowId
|
* GET /genome/comparison-averages/:cowId
|
||||||
* 개체 기준 전국/지역/농장 카테고리별 평균 EBV 비교 데이터
|
* 개체 기준 전국/지역/농장 카테고리별 평균 EBV 비교 데이터
|
||||||
@@ -133,32 +100,6 @@ export class GenomeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Genome Trait Detail endpoints
|
|
||||||
@Get('trait-detail/:requestId')
|
|
||||||
findTraitDetailsByRequestId(@Param('requestId') requestId: string) {
|
|
||||||
return this.genomeService.findTraitDetailsByRequestId(+requestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('trait-detail/cow/:cowId')
|
|
||||||
findTraitDetailsByCowId(@Param('cowId') cowId: string) {
|
|
||||||
return this.genomeService.findTraitDetailsByCowId(cowId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('trait-detail')
|
|
||||||
createTraitDetail(@Body() data: Partial<GenomeTraitDetailModel>) {
|
|
||||||
return this.genomeService.createTraitDetail(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /genome/check-cow/:cowId
|
|
||||||
* 특정 개체 상세 정보 조회 (디버깅용)
|
|
||||||
*/
|
|
||||||
@Public()
|
|
||||||
@Get('check-cow/:cowId')
|
|
||||||
checkSpecificCow(@Param('cowId') cowId: string) {
|
|
||||||
return this.genomeService.checkSpecificCows([cowId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /genome/yearly-trait-trend/:farmNo
|
* GET /genome/yearly-trait-trend/:farmNo
|
||||||
* 연도별 유전능력 추이 (형질별/카테고리별)
|
* 연도별 유전능력 추이 (형질별/카테고리별)
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import {
|
|||||||
isValidGenomeAnalysis,
|
isValidGenomeAnalysis,
|
||||||
VALID_CHIP_SIRE_NAME
|
VALID_CHIP_SIRE_NAME
|
||||||
} from '../common/config/GenomeAnalysisConfig';
|
} from '../common/config/GenomeAnalysisConfig';
|
||||||
|
import {
|
||||||
|
ALL_TRAITS,
|
||||||
|
NEGATIVE_TRAITS,
|
||||||
|
TRAIT_CATEGORY_MAP,
|
||||||
|
getTraitCategory,
|
||||||
|
} from '../common/const/TraitTypes';
|
||||||
import { CowModel } from '../cow/entities/cow.entity';
|
import { CowModel } from '../cow/entities/cow.entity';
|
||||||
import { FarmModel } from '../farm/entities/farm.entity';
|
import { FarmModel } from '../farm/entities/farm.entity';
|
||||||
import { GenomeRequestModel } from './entities/genome-request.entity';
|
import { GenomeRequestModel } from './entities/genome-request.entity';
|
||||||
@@ -12,68 +18,6 @@ import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity';
|
|||||||
import { MptModel } from '../mpt/entities/mpt.entity';
|
import { MptModel } from '../mpt/entities/mpt.entity';
|
||||||
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
|
import { GeneDetailModel } from '../gene/entities/gene-detail.entity';
|
||||||
|
|
||||||
/**
|
|
||||||
* 낮을수록 좋은 형질 목록 (부호 반전 필요)
|
|
||||||
* - 등지방두께: 지방이 얇을수록(EBV가 낮을수록) 좋은 형질
|
|
||||||
* - 선발지수 계산 시 EBV 부호를 반전하여 적용
|
|
||||||
*/
|
|
||||||
const NEGATIVE_TRAITS = ['등지방두께'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 형질명 → 카테고리 매핑 상수
|
|
||||||
* - 성장: 월령별 체중 관련 형질
|
|
||||||
* - 생산: 도체(도축 후 고기) 품질 관련 형질
|
|
||||||
* - 체형: 소의 신체 구조/외형 관련 형질
|
|
||||||
* - 무게: 각 부위별 실제 무게 (단위: kg)
|
|
||||||
* - 비율: 각 부위별 비율 (단위: %)
|
|
||||||
*/
|
|
||||||
const TRAIT_CATEGORY_MAP: Record<string, string> = {
|
|
||||||
// 성장 카테고리 - 월령별 체중
|
|
||||||
'12개월령체중': '성장',
|
|
||||||
|
|
||||||
// 생산 카테고리 - 도체(도축 후 고기) 품질
|
|
||||||
'도체중': '생산', // 도축 후 고기 무게
|
|
||||||
'등심단면적': '생산', // 등심의 단면 크기 (넓을수록 좋음)
|
|
||||||
'등지방두께': '생산', // 등 부위 지방 두께 (적당해야 좋음)
|
|
||||||
'근내지방도': '생산', // 마블링 정도 (높을수록 고급육)
|
|
||||||
|
|
||||||
// 체형 카테고리 - 소의 신체 구조/외형
|
|
||||||
'체고': '체형', // 어깨 높이
|
|
||||||
'십자': '체형', // 십자부(엉덩이) 높이
|
|
||||||
'체장': '체형', // 몸통 길이
|
|
||||||
'흉심': '체형', // 가슴 깊이
|
|
||||||
'흉폭': '체형', // 가슴 너비
|
|
||||||
'고장': '체형', // 엉덩이 길이
|
|
||||||
'요각폭': '체형', // 허리뼈 너비
|
|
||||||
'곤폭': '체형', // 좌골(엉덩이뼈) 너비
|
|
||||||
'좌골폭': '체형', // 좌골 너비
|
|
||||||
'흉위': '체형', // 가슴둘레
|
|
||||||
|
|
||||||
// 무게 카테고리 - 부위별 실제 무게 (kg)
|
|
||||||
'안심weight': '무게', // 안심 무게
|
|
||||||
'등심weight': '무게', // 등심 무게
|
|
||||||
'채끝weight': '무게', // 채끝 무게
|
|
||||||
'목심weight': '무게', // 목심 무게
|
|
||||||
'앞다리weight': '무게', // 앞다리 무게
|
|
||||||
'우둔weight': '무게', // 우둔 무게
|
|
||||||
'설도weight': '무게', // 설도 무게
|
|
||||||
'사태weight': '무게', // 사태 무게
|
|
||||||
'양지weight': '무게', // 양지 무게
|
|
||||||
'갈비weight': '무게', // 갈비 무게
|
|
||||||
|
|
||||||
// 비율 카테고리 - 부위별 비율 (%)
|
|
||||||
'안심rate': '비율', // 안심 비율
|
|
||||||
'등심rate': '비율', // 등심 비율
|
|
||||||
'채끝rate': '비율', // 채끝 비율
|
|
||||||
'목심rate': '비율', // 목심 비율
|
|
||||||
'앞다리rate': '비율', // 앞다리 비율
|
|
||||||
'우둔rate': '비율', // 우둔 비율
|
|
||||||
'설도rate': '비율', // 설도 비율
|
|
||||||
'사태rate': '비율', // 사태 비율
|
|
||||||
'양지rate': '비율', // 양지 비율
|
|
||||||
'갈비rate': '비율', // 갈비 비율
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리별 평균 EBV(추정육종가) 응답 DTO
|
* 카테고리별 평균 EBV(추정육종가) 응답 DTO
|
||||||
*/
|
*/
|
||||||
@@ -155,177 +99,6 @@ export class GenomeService {
|
|||||||
// 대시보드 통계 관련 메서드
|
// 대시보드 통계 관련 메서드
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
/**
|
|
||||||
* 농가별 형질 비교 데이터 (농가 vs 지역 vs 전국)
|
|
||||||
* - 각 형질별로 원본 EBV, 중요도(가중치), 적용 EBV
|
|
||||||
* - 보은군 전체 평균, 농가 평균 비교
|
|
||||||
*
|
|
||||||
* @param farmNo - 농장 번호
|
|
||||||
*/
|
|
||||||
async getFarmTraitComparison(farmNo: number): Promise<{
|
|
||||||
farmName: string;
|
|
||||||
regionName: string;
|
|
||||||
totalFarmAnimals: number;
|
|
||||||
totalRegionAnimals: number;
|
|
||||||
traits: {
|
|
||||||
traitName: string;
|
|
||||||
category: string;
|
|
||||||
// 농가 데이터
|
|
||||||
farmAvgEbv: number;
|
|
||||||
farmCount: number;
|
|
||||||
farmPercentile: number;
|
|
||||||
// 지역(보은군) 데이터
|
|
||||||
regionAvgEbv: number;
|
|
||||||
regionCount: number;
|
|
||||||
// 전국 데이터
|
|
||||||
nationAvgEbv: number;
|
|
||||||
nationCount: number;
|
|
||||||
// 비교
|
|
||||||
diffFromRegion: number; // 지역 대비 차이
|
|
||||||
diffFromNation: number; // 전국 대비 차이
|
|
||||||
}[];
|
|
||||||
}> {
|
|
||||||
// Step 1: 농장 정보 조회
|
|
||||||
const farm = await this.farmRepository.findOne({
|
|
||||||
where: { pkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
const regionSi = farm?.regionSi || '보은군';
|
|
||||||
const farmName = farm?.farmerName || '농장';
|
|
||||||
|
|
||||||
// Step 2: 농가의 분석 완료된 개체들의 형질 데이터 조회
|
|
||||||
const farmRequestsRaw = await this.genomeRequestRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, chipSireName: VALID_CHIP_SIRE_NAME, delDt: IsNull() },
|
|
||||||
relations: ['cow'],
|
|
||||||
});
|
|
||||||
// 유효 조건 필터 적용 (chipDamName 제외 조건 + cowId 제외 목록)
|
|
||||||
const farmRequests = farmRequestsRaw.filter(r =>
|
|
||||||
isValidGenomeAnalysis(r.chipSireName, r.chipDamName, r.cow?.cowId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const farmTraitMap = new Map<string, { sum: number; percentileSum: number; count: number; category: string }>();
|
|
||||||
|
|
||||||
for (const request of farmRequests) {
|
|
||||||
// cowId로 직접 형질 데이터 조회
|
|
||||||
const details = await this.genomeTraitDetailRepository.find({
|
|
||||||
where: { cowId: request.cow?.cowId, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
if (details.length === 0) continue;
|
|
||||||
|
|
||||||
for (const detail of details) {
|
|
||||||
if (detail.traitEbv !== null && detail.traitName) {
|
|
||||||
const traitName = detail.traitName;
|
|
||||||
const category = TRAIT_CATEGORY_MAP[traitName] || '기타';
|
|
||||||
|
|
||||||
if (!farmTraitMap.has(traitName)) {
|
|
||||||
farmTraitMap.set(traitName, { sum: 0, percentileSum: 0, count: 0, category });
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = farmTraitMap.get(traitName)!;
|
|
||||||
t.sum += Number(detail.traitEbv);
|
|
||||||
t.percentileSum += Number(detail.traitPercentile) || 50;
|
|
||||||
t.count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: 지역(보은군) 전체 형질 데이터 조회
|
|
||||||
const regionDetails = await this.genomeTraitDetailRepository
|
|
||||||
.createQueryBuilder('detail')
|
|
||||||
.innerJoin('tb_genome_request', 'request', 'detail.fk_request_no = request.pk_request_no')
|
|
||||||
.innerJoin('tb_farm', 'farm', 'request.fk_farm_no = farm.pk_farm_no')
|
|
||||||
.where('detail.delDt IS NULL')
|
|
||||||
.andWhere('detail.traitEbv IS NOT NULL')
|
|
||||||
.andWhere('request.chip_sire_name = :match', { match: '일치' })
|
|
||||||
.andWhere('farm.region_si = :regionSi', { regionSi })
|
|
||||||
.select(['detail.traitName', 'detail.traitEbv'])
|
|
||||||
.getRawMany();
|
|
||||||
|
|
||||||
const regionTraitMap = new Map<string, { sum: number; count: number }>();
|
|
||||||
for (const detail of regionDetails) {
|
|
||||||
const traitName = detail.detail_trait_name;
|
|
||||||
const ebv = parseFloat(detail.detail_trait_ebv);
|
|
||||||
if (!traitName || isNaN(ebv)) continue;
|
|
||||||
|
|
||||||
if (!regionTraitMap.has(traitName)) {
|
|
||||||
regionTraitMap.set(traitName, { sum: 0, count: 0 });
|
|
||||||
}
|
|
||||||
const t = regionTraitMap.get(traitName)!;
|
|
||||||
t.sum += ebv;
|
|
||||||
t.count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: 전국 형질 데이터 조회
|
|
||||||
const nationDetails = await this.genomeTraitDetailRepository
|
|
||||||
.createQueryBuilder('detail')
|
|
||||||
.innerJoin('tb_genome_request', 'request', 'detail.fk_request_no = request.pk_request_no')
|
|
||||||
.where('detail.delDt IS NULL')
|
|
||||||
.andWhere('detail.traitEbv IS NOT NULL')
|
|
||||||
.andWhere('request.chip_sire_name = :match', { match: '일치' })
|
|
||||||
.select(['detail.traitName', 'detail.traitEbv'])
|
|
||||||
.getRawMany();
|
|
||||||
|
|
||||||
const nationTraitMap = new Map<string, { sum: number; count: number }>();
|
|
||||||
for (const detail of nationDetails) {
|
|
||||||
const traitName = detail.detail_trait_name;
|
|
||||||
const ebv = parseFloat(detail.detail_trait_ebv);
|
|
||||||
if (!traitName || isNaN(ebv)) continue;
|
|
||||||
|
|
||||||
if (!nationTraitMap.has(traitName)) {
|
|
||||||
nationTraitMap.set(traitName, { sum: 0, count: 0 });
|
|
||||||
}
|
|
||||||
const t = nationTraitMap.get(traitName)!;
|
|
||||||
t.sum += ebv;
|
|
||||||
t.count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: 결과 조합 (35개 전체 형질)
|
|
||||||
const traits: any[] = [];
|
|
||||||
const allTraits = Object.keys(TRAIT_CATEGORY_MAP);
|
|
||||||
|
|
||||||
for (const traitName of allTraits) {
|
|
||||||
const farmData = farmTraitMap.get(traitName);
|
|
||||||
const regionData = regionTraitMap.get(traitName);
|
|
||||||
const nationData = nationTraitMap.get(traitName);
|
|
||||||
|
|
||||||
const farmAvgEbv = farmData ? Math.round((farmData.sum / farmData.count) * 100) / 100 : 0;
|
|
||||||
const farmPercentile = farmData ? Math.round((farmData.percentileSum / farmData.count) * 100) / 100 : 50;
|
|
||||||
const regionAvgEbv = regionData ? Math.round((regionData.sum / regionData.count) * 100) / 100 : 0;
|
|
||||||
const nationAvgEbv = nationData ? Math.round((nationData.sum / nationData.count) * 100) / 100 : 0;
|
|
||||||
|
|
||||||
traits.push({
|
|
||||||
traitName,
|
|
||||||
category: TRAIT_CATEGORY_MAP[traitName] || '기타',
|
|
||||||
farmAvgEbv,
|
|
||||||
farmCount: farmData?.count || 0,
|
|
||||||
farmPercentile,
|
|
||||||
regionAvgEbv,
|
|
||||||
regionCount: regionData?.count || 0,
|
|
||||||
nationAvgEbv,
|
|
||||||
nationCount: nationData?.count || 0,
|
|
||||||
diffFromRegion: Math.round((farmAvgEbv - regionAvgEbv) * 100) / 100,
|
|
||||||
diffFromNation: Math.round((farmAvgEbv - nationAvgEbv) * 100) / 100,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 지역 개체 수 계산
|
|
||||||
const regionAnimalCount = await this.genomeRequestRepository
|
|
||||||
.createQueryBuilder('request')
|
|
||||||
.innerJoin('tb_farm', 'farm', 'request.fk_farm_no = farm.pk_farm_no')
|
|
||||||
.where('request.chip_sire_name = :match', { match: '일치' })
|
|
||||||
.andWhere('request.del_dt IS NULL')
|
|
||||||
.andWhere('farm.region_si = :regionSi', { regionSi })
|
|
||||||
.getCount();
|
|
||||||
|
|
||||||
return {
|
|
||||||
farmName,
|
|
||||||
regionName: regionSi,
|
|
||||||
totalFarmAnimals: farmRequests.length,
|
|
||||||
totalRegionAnimals: regionAnimalCount,
|
|
||||||
traits,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드용 농가 통계 데이터
|
* 대시보드용 농가 통계 데이터
|
||||||
* - 연도별 분석 현황
|
* - 연도별 분석 현황
|
||||||
@@ -917,35 +690,6 @@ export class GenomeService {
|
|||||||
// 유전체 분석 의뢰 (Genome Request) 관련 메서드
|
// 유전체 분석 의뢰 (Genome Request) 관련 메서드
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 유전체 분석 의뢰 목록 조회
|
|
||||||
*
|
|
||||||
* @returns 삭제되지 않은 모든 분석 의뢰 목록
|
|
||||||
* - cow, farm 관계 데이터 포함
|
|
||||||
* - 등록일(regDt) 기준 내림차순 정렬 (최신순)
|
|
||||||
*/
|
|
||||||
async findAllRequests(): Promise<GenomeRequestModel[]> {
|
|
||||||
return this.genomeRequestRepository.find({
|
|
||||||
where: { delDt: IsNull() }, // 삭제되지 않은 데이터만
|
|
||||||
relations: ['cow', 'farm'], // 개체, 농장 정보 JOIN
|
|
||||||
order: { regDt: 'DESC' }, // 최신순 정렬
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 개체 PK 번호로 해당 개체의 분석 의뢰 목록 조회
|
|
||||||
*
|
|
||||||
* @param cowNo - 개체 PK 번호 (pkCowNo)
|
|
||||||
* @returns 해당 개체의 모든 분석 의뢰 목록 (최신순)
|
|
||||||
*/
|
|
||||||
async findRequestsByCowId(cowNo: number): Promise<GenomeRequestModel[]> {
|
|
||||||
return this.genomeRequestRepository.find({
|
|
||||||
where: { fkCowNo: cowNo, delDt: IsNull() },
|
|
||||||
relations: ['cow', 'farm'],
|
|
||||||
order: { regDt: 'DESC' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 개체식별번호(cowId)로 유전체 데이터 조회
|
* 개체식별번호(cowId)로 유전체 데이터 조회
|
||||||
* 가장 최근 분석 의뢰의 형질 상세 정보까지 포함하여 반환
|
* 가장 최근 분석 의뢰의 형질 상세 정보까지 포함하여 반환
|
||||||
@@ -991,19 +735,7 @@ export class GenomeService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: 형질명으로 카테고리를 추정하는 내부 함수
|
// Step 4: 프론트엔드에서 사용할 형식으로 데이터 가공하여 반환
|
||||||
const getCategoryFromTraitName = (traitName: string): string => {
|
|
||||||
// 성장 카테고리: 월령별 체중
|
|
||||||
if (['12개월령체중', '18개월령체중', '24개월령체중'].includes(traitName)) return '성장';
|
|
||||||
// 생산 카테고리: 도체 품질
|
|
||||||
if (['도체중', '등심단면적', '등지방두께', '근내지방도'].includes(traitName)) return '생산';
|
|
||||||
// 체형 카테고리: 신체 구조
|
|
||||||
if (traitName.includes('체형') || traitName.includes('체고') || traitName.includes('십자부')) return '체형';
|
|
||||||
// 그 외 기타
|
|
||||||
return '기타';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Step 5: 프론트엔드에서 사용할 형식으로 데이터 가공하여 반환
|
|
||||||
return [{
|
return [{
|
||||||
request: latestRequest, // 분석 의뢰 정보
|
request: latestRequest, // 분석 의뢰 정보
|
||||||
genomeCows: traitDetails.map(detail => ({
|
genomeCows: traitDetails.map(detail => ({
|
||||||
@@ -1012,27 +744,13 @@ export class GenomeService {
|
|||||||
percentile: detail.traitPercentile, // 백분위 순위
|
percentile: detail.traitPercentile, // 백분위 순위
|
||||||
traitInfo: {
|
traitInfo: {
|
||||||
traitNm: detail.traitName, // 형질명
|
traitNm: detail.traitName, // 형질명
|
||||||
traitCtgry: getCategoryFromTraitName(detail.traitName || ''), // 카테고리
|
traitCtgry: getTraitCategory(detail.traitName || ''), // 카테고리 (공통 함수 사용)
|
||||||
traitDesc: '', // 형질 설명 (빈값)
|
traitDesc: '', // 형질 설명 (빈값)
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 농장 PK 번호로 해당 농장의 분석 의뢰 목록 조회
|
|
||||||
*
|
|
||||||
* @param farmNo - 농장 PK 번호 (pkFarmNo)
|
|
||||||
* @returns 해당 농장의 모든 분석 의뢰 목록 (최신순)
|
|
||||||
*/
|
|
||||||
async findRequestsByFarmId(farmNo: number): Promise<GenomeRequestModel[]> {
|
|
||||||
return this.genomeRequestRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
relations: ['cow', 'farm'],
|
|
||||||
order: { regDt: 'DESC' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 개체식별번호(cowId)로 유전체 분석 의뢰 정보 조회
|
* 개체식별번호(cowId)로 유전체 분석 의뢰 정보 조회
|
||||||
*
|
*
|
||||||
@@ -1059,61 +777,6 @@ export class GenomeService {
|
|||||||
return request || null;
|
return request || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ===========================================================================================
|
|
||||||
* 유전체 분석 요청 관련 메서드
|
|
||||||
* ===========================================================================================
|
|
||||||
* 새로운 유전체 분석 의뢰 생성
|
|
||||||
*
|
|
||||||
* @param data - 생성할 분석 의뢰 데이터 (Partial: 일부 필드만 입력 가능)
|
|
||||||
* @returns 생성된 분석 의뢰 엔티티
|
|
||||||
*/
|
|
||||||
async createRequest(data: Partial<GenomeRequestModel>): Promise<GenomeRequestModel> {
|
|
||||||
// 엔티티 인스턴스 생성
|
|
||||||
const request = this.genomeRequestRepository.create(data);
|
|
||||||
// DB에 저장 후 반환
|
|
||||||
return this.genomeRequestRepository.save(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 형질 상세 (Genome Trait Detail) 관련 메서드
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 분석 의뢰 PK로 해당 의뢰의 형질 상세 목록 조회
|
|
||||||
*
|
|
||||||
* @param requestNo - 분석 의뢰 PK 번호 (pkRequestNo)
|
|
||||||
* @returns 해당 의뢰의 모든 형질 상세 목록
|
|
||||||
*/
|
|
||||||
async findTraitDetailsByRequestId(requestNo: number): Promise<GenomeTraitDetailModel[]> {
|
|
||||||
return this.genomeTraitDetailRepository.find({
|
|
||||||
where: { fkRequestNo: requestNo, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* cowId로 해당 개체의 형질 상세 목록 조회
|
|
||||||
*
|
|
||||||
* @param cowId - 개체식별번호 (KOR...)
|
|
||||||
* @returns 해당 개체의 모든 형질 상세 목록
|
|
||||||
*/
|
|
||||||
async findTraitDetailsByCowId(cowId: string): Promise<GenomeTraitDetailModel[]> {
|
|
||||||
return this.genomeTraitDetailRepository.find({
|
|
||||||
where: { cowId: cowId, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 새로운 형질 상세 데이터 생성
|
|
||||||
*
|
|
||||||
* @param data - 생성할 형질 상세 데이터
|
|
||||||
* @returns 생성된 형질 상세 엔티티
|
|
||||||
*/
|
|
||||||
async createTraitDetail(data: Partial<GenomeTraitDetailModel>): Promise<GenomeTraitDetailModel> {
|
|
||||||
const detail = this.genomeTraitDetailRepository.create(data);
|
|
||||||
return this.genomeTraitDetailRepository.save(detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 비교 분석 (Comparison) 관련 메서드
|
// 비교 분석 (Comparison) 관련 메서드
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -1874,17 +1537,7 @@ export class GenomeService {
|
|||||||
traitDetailsByCowId.get(detail.cowId)!.push(detail);
|
traitDetailsByCowId.get(detail.cowId)!.push(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 35개 전체 형질 조건 (기본값)
|
// 4. inputTraitConditions가 있으면 사용, 없으면 35개 형질 기본값 사용 (ALL_TRAITS는 공통 상수에서 import)
|
||||||
const ALL_TRAITS = [
|
|
||||||
'12개월령체중',
|
|
||||||
'도체중', '등심단면적', '등지방두께', '근내지방도',
|
|
||||||
'체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
|
||||||
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
|
||||||
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
|
||||||
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
|
||||||
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
|
||||||
];
|
|
||||||
// inputTraitConditions가 있으면 사용, 없으면 35개 형질 기본값 사용
|
|
||||||
const traitConditions = inputTraitConditions && inputTraitConditions.length > 0
|
const traitConditions = inputTraitConditions && inputTraitConditions.length > 0
|
||||||
? inputTraitConditions // 프론트에서 보낸 형질사용
|
? inputTraitConditions // 프론트에서 보낸 형질사용
|
||||||
: ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 })); // 기본값 사용
|
: ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 })); // 기본값 사용
|
||||||
@@ -2003,41 +1656,6 @@ export class GenomeService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 개체들의 상세 정보 조회 (디버깅용)
|
|
||||||
*/
|
|
||||||
async checkSpecificCows(cowIds: string[]): Promise<any[]> {
|
|
||||||
const results = [];
|
|
||||||
for (const cowId of cowIds) {
|
|
||||||
const request = await this.genomeRequestRepository.findOne({
|
|
||||||
where: { delDt: IsNull() },
|
|
||||||
relations: ['cow'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// cowId로 조회
|
|
||||||
const cow = await this.cowRepository.findOne({
|
|
||||||
where: { cowId, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (cow) {
|
|
||||||
const req = await this.genomeRequestRepository.findOne({
|
|
||||||
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
|
|
||||||
});
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
cowId,
|
|
||||||
chipSireName: req?.chipSireName,
|
|
||||||
chipDamName: req?.chipDamName,
|
|
||||||
requestDt: req?.requestDt,
|
|
||||||
cowRemarks: req?.cowRemarks,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
results.push({ cowId, error: 'cow not found' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 연도별 유전능력 추이 (형질별/카테고리별)
|
* 연도별 유전능력 추이 (형질별/카테고리별)
|
||||||
* 최적화: N+1 쿼리 문제 해결 - 단일 쿼리로 모든 데이터 조회
|
* 최적화: N+1 쿼리 문제 해결 - 단일 쿼리로 모든 데이터 조회
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import { IsNotEmpty, IsString, IsOptional, IsInt, MaxLength, IsIn } from 'class-validator';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 도움말 생성 DTO
|
|
||||||
*
|
|
||||||
* @export
|
|
||||||
* @class CreateHelpDto
|
|
||||||
*/
|
|
||||||
export class CreateHelpDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
@IsIn(['SNP', 'GENOME', 'MPT'])
|
|
||||||
@MaxLength(20)
|
|
||||||
helpCtgry: string;
|
|
||||||
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(100)
|
|
||||||
targetNm: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(200)
|
|
||||||
helpTitle?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
helpShort?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
helpFull?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(500)
|
|
||||||
helpImageUrl?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(500)
|
|
||||||
helpVideoUrl?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(500)
|
|
||||||
helpLinkUrl?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
displayOrder?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@IsIn(['Y', 'N'])
|
|
||||||
@MaxLength(1)
|
|
||||||
useYn?: string;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { IsOptional, IsString, IsIn, MaxLength } from 'class-validator';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 도움말 필터링 DTO
|
|
||||||
*
|
|
||||||
* @export
|
|
||||||
* @class FilterHelpDto
|
|
||||||
*/
|
|
||||||
export class FilterHelpDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@IsIn(['SNP', 'GENOME', 'MPT'])
|
|
||||||
@MaxLength(20)
|
|
||||||
helpCtgry?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(100)
|
|
||||||
targetNm?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@IsIn(['Y', 'N'])
|
|
||||||
@MaxLength(1)
|
|
||||||
useYn?: string;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
|
||||||
import { CreateHelpDto } from './create-help.dto';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 도움말 수정 DTO
|
|
||||||
*
|
|
||||||
* @export
|
|
||||||
* @class UpdateHelpDto
|
|
||||||
* @extends {PartialType(CreateHelpDto)}
|
|
||||||
*/
|
|
||||||
export class UpdateHelpDto extends PartialType(CreateHelpDto) {}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { BaseModel } from "src/common/entities/base.entity";
|
|
||||||
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
|
||||||
|
|
||||||
@Entity({ name: "tb_help" })
|
|
||||||
export class HelpModel extends BaseModel {
|
|
||||||
@PrimaryGeneratedColumn({
|
|
||||||
name: "pk_help_no",
|
|
||||||
type: "int",
|
|
||||||
comment: "도움말 번호",
|
|
||||||
})
|
|
||||||
pkHelpNo: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: "help_ctgry",
|
|
||||||
type: "varchar",
|
|
||||||
length: 20,
|
|
||||||
nullable: false,
|
|
||||||
comment: "분류 (SNP/GENOME/MPT)",
|
|
||||||
})
|
|
||||||
helpCtgry: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: "target_nm",
|
|
||||||
type: "varchar",
|
|
||||||
length: 100,
|
|
||||||
nullable: false,
|
|
||||||
comment: "대상명 (PLAG1, 도체중, 혈당 등)",
|
|
||||||
})
|
|
||||||
targetNm: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: "help_title",
|
|
||||||
type: "varchar",
|
|
||||||
length: 200,
|
|
||||||
nullable: true,
|
|
||||||
comment: "제목",
|
|
||||||
})
|
|
||||||
helpTitle: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: "help_short",
|
|
||||||
type: "text",
|
|
||||||
nullable: true,
|
|
||||||
comment: "짧은 설명 (툴팁용)",
|
|
||||||
})
|
|
||||||
helpShort: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: "help_full",
|
|
||||||
type: "text",
|
|
||||||
nullable: true,
|
|
||||||
comment: "상세 설명 (사이드패널용)",
|
|
||||||
})
|
|
||||||
helpFull: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: "help_image_url",
|
|
||||||
type: "varchar",
|
|
||||||
length: 500,
|
|
||||||
nullable: true,
|
|
||||||
comment: "이미지 URL",
|
|
||||||
})
|
|
||||||
helpImageUrl: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: "help_video_url",
|
|
||||||
type: "varchar",
|
|
||||||
length: 500,
|
|
||||||
nullable: true,
|
|
||||||
comment: "영상 URL",
|
|
||||||
})
|
|
||||||
helpVideoUrl: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: "help_link_url",
|
|
||||||
type: "varchar",
|
|
||||||
length: 500,
|
|
||||||
nullable: true,
|
|
||||||
comment: "참고 링크 URL",
|
|
||||||
})
|
|
||||||
helpLinkUrl: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: "display_order",
|
|
||||||
type: "int",
|
|
||||||
nullable: true,
|
|
||||||
comment: "표시 순서",
|
|
||||||
})
|
|
||||||
displayOrder: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: "use_yn",
|
|
||||||
type: "char",
|
|
||||||
length: 1,
|
|
||||||
nullable: false,
|
|
||||||
default: "Y",
|
|
||||||
comment: "사용 여부 (Y/N)",
|
|
||||||
})
|
|
||||||
useYn: string;
|
|
||||||
|
|
||||||
// BaseModel에서 상속받는 컬럼들:
|
|
||||||
// - regDt: 등록일시
|
|
||||||
// - updtDt: 수정일시
|
|
||||||
// - regIp: 등록 IP
|
|
||||||
// - updtIp: 수정 IP
|
|
||||||
// - regUserId: 등록자 ID
|
|
||||||
// - updtUserId: 수정자 ID
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, Req } from '@nestjs/common';
|
|
||||||
import { HelpService } from './help.service';
|
|
||||||
import { CreateHelpDto } from './dto/create-help.dto';
|
|
||||||
import { UpdateHelpDto } from './dto/update-help.dto';
|
|
||||||
import { FilterHelpDto } from './dto/filter-help.dto';
|
|
||||||
import { Request } from 'express';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Help Controller
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 도움말/툴팁 시스템 API 엔드포인트를 제공합니다.
|
|
||||||
*
|
|
||||||
* 주요 기능:
|
|
||||||
* - 도움말 CRUD (생성, 조회, 수정, 삭제)
|
|
||||||
* - 카테고리별 조회 (SNP/GENOME/MPT)
|
|
||||||
* - 대상명별 조회 (PLAG1, 도체중 등)
|
|
||||||
* - 툴팁 데이터 제공
|
|
||||||
*
|
|
||||||
* @export
|
|
||||||
* @class HelpController
|
|
||||||
*/
|
|
||||||
@Controller('help')
|
|
||||||
export class HelpController {
|
|
||||||
constructor(private readonly helpService: HelpService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /help - 도움말 생성 (관리자)
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 새로운 도움말을 생성합니다.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // POST /help
|
|
||||||
* {
|
|
||||||
* "helpCtgry": "SNP",
|
|
||||||
* "targetNm": "PLAG1",
|
|
||||||
* "helpTitle": "PLAG1 유전자란?",
|
|
||||||
* "helpShort": "체고 및 성장 관련 유전자",
|
|
||||||
* "helpFull": "PLAG1은 소의 체고와 성장에 영향을 미치는 주요 유전자입니다...",
|
|
||||||
* "displayOrder": 1,
|
|
||||||
* "useYn": "Y"
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @param {CreateHelpDto} createHelpDto - 생성할 도움말 데이터
|
|
||||||
* @param {Request} req - Express Request 객체
|
|
||||||
* @returns {Promise<HelpModel>}
|
|
||||||
*/
|
|
||||||
@Post()
|
|
||||||
async create(@Body() createHelpDto: CreateHelpDto, @Req() req: Request) {
|
|
||||||
const userId = (req as any).user?.userId || 'system';
|
|
||||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
|
||||||
return await this.helpService.create(createHelpDto, userId, ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /help - 전체 도움말 목록 조회
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 전체 도움말 목록을 조회합니다. 필터 조건을 통해 검색 가능합니다.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // GET /help
|
|
||||||
* // GET /help?helpCtgry=SNP
|
|
||||||
* // GET /help?useYn=Y
|
|
||||||
* // GET /help?targetNm=PLAG1
|
|
||||||
*
|
|
||||||
* @param {FilterHelpDto} filterDto - 필터 조건 (선택)
|
|
||||||
* @returns {Promise<HelpModel[]>}
|
|
||||||
*/
|
|
||||||
@Get()
|
|
||||||
async findAll(@Query() filterDto: FilterHelpDto) {
|
|
||||||
return await this.helpService.findAll(filterDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /help/category/:category - 카테고리별 도움말 조회
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 특정 카테고리(SNP/GENOME/MPT)의 모든 도움말을 조회합니다.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // GET /help/category/SNP
|
|
||||||
* // GET /help/category/GENOME
|
|
||||||
* // GET /help/category/MPT
|
|
||||||
*
|
|
||||||
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
|
||||||
* @returns {Promise<HelpModel[]>}
|
|
||||||
*/
|
|
||||||
@Get('category/:category')
|
|
||||||
async findByCategory(@Param('category') category: string) {
|
|
||||||
return await this.helpService.findByCategory(category);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /help/:category/:targetNm - 특정 대상의 도움말 조회
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 특정 카테고리와 대상명에 해당하는 도움말을 조회합니다.
|
|
||||||
* 툴팁이나 사이드패널에서 사용됩니다.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // GET /help/SNP/PLAG1
|
|
||||||
* // GET /help/GENOME/도체중
|
|
||||||
* // GET /help/MPT/혈당
|
|
||||||
*
|
|
||||||
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
|
||||||
* @param {string} targetNm - 대상명 (PLAG1, 도체중 등)
|
|
||||||
* @returns {Promise<HelpModel>}
|
|
||||||
*/
|
|
||||||
@Get(':category/:targetNm')
|
|
||||||
async findByTarget(
|
|
||||||
@Param('category') category: string,
|
|
||||||
@Param('targetNm') targetNm: string,
|
|
||||||
) {
|
|
||||||
return await this.helpService.findByTarget(category, targetNm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /help/id/:id - 도움말 단건 조회
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 도움말 번호로 단건을 조회합니다.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // GET /help/id/1
|
|
||||||
*
|
|
||||||
* @param {number} id - 도움말 번호
|
|
||||||
* @returns {Promise<HelpModel>}
|
|
||||||
*/
|
|
||||||
@Get('id/:id')
|
|
||||||
async findOne(@Param('id') id: number) {
|
|
||||||
return await this.helpService.findOne(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /help/:id - 도움말 수정 (관리자)
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 기존 도움말을 수정합니다.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // PUT /help/1
|
|
||||||
* {
|
|
||||||
* "helpTitle": "수정된 제목",
|
|
||||||
* "helpShort": "수정된 짧은 설명",
|
|
||||||
* "displayOrder": 2
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @param {number} id - 도움말 번호
|
|
||||||
* @param {UpdateHelpDto} updateHelpDto - 수정할 데이터
|
|
||||||
* @param {Request} req - Express Request 객체
|
|
||||||
* @returns {Promise<HelpModel>}
|
|
||||||
*/
|
|
||||||
@Put(':id')
|
|
||||||
async update(
|
|
||||||
@Param('id') id: number,
|
|
||||||
@Body() updateHelpDto: UpdateHelpDto,
|
|
||||||
@Req() req: Request,
|
|
||||||
) {
|
|
||||||
const userId = (req as any).user?.userId || 'system';
|
|
||||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
|
||||||
return await this.helpService.update(id, updateHelpDto, userId, ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /help/:id - 도움말 삭제 (관리자)
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 도움말을 삭제합니다 (soft delete - useYn = 'N').
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // DELETE /help/1
|
|
||||||
*
|
|
||||||
* @param {number} id - 도움말 번호
|
|
||||||
* @param {Request} req - Express Request 객체
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
@Delete(':id')
|
|
||||||
async remove(@Param('id') id: number, @Req() req: Request) {
|
|
||||||
const userId = (req as any).user?.userId || 'system';
|
|
||||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
|
||||||
return await this.helpService.remove(id, userId, ip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { HelpController } from './help.controller';
|
|
||||||
import { HelpService } from './help.service';
|
|
||||||
import { HelpModel } from './entities/help.entity';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Help Module
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 도움말/툴팁 시스템 모듈입니다.
|
|
||||||
* SNP, GENOME, MPT 등의 용어에 대한 설명을 제공합니다.
|
|
||||||
*
|
|
||||||
* 주요 기능:
|
|
||||||
* - 도움말 CRUD
|
|
||||||
* - 카테고리별 조회
|
|
||||||
* - 툴팁/사이드패널 데이터 제공
|
|
||||||
*
|
|
||||||
* @export
|
|
||||||
* @class HelpModule
|
|
||||||
*/
|
|
||||||
@Module({
|
|
||||||
imports: [TypeOrmModule.forFeature([HelpModel])],
|
|
||||||
controllers: [HelpController],
|
|
||||||
providers: [HelpService],
|
|
||||||
exports: [HelpService],
|
|
||||||
})
|
|
||||||
export class HelpModule {}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { HelpModel } from './entities/help.entity';
|
|
||||||
import { CreateHelpDto } from './dto/create-help.dto';
|
|
||||||
import { UpdateHelpDto } from './dto/update-help.dto';
|
|
||||||
import { FilterHelpDto } from './dto/filter-help.dto';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Help Service
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 도움말/툴팁 시스템 서비스입니다.
|
|
||||||
* SNP, GENOME, MPT 등의 용어에 대한 도움말을 제공합니다.
|
|
||||||
*
|
|
||||||
* @export
|
|
||||||
* @class HelpService
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class HelpService {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(HelpModel)
|
|
||||||
private readonly helpRepository: Repository<HelpModel>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 도움말 생성
|
|
||||||
*
|
|
||||||
* @param {CreateHelpDto} createHelpDto - 생성할 도움말 데이터
|
|
||||||
* @param {string} userId - 생성자 ID
|
|
||||||
* @param {string} ip - 생성자 IP
|
|
||||||
* @returns {Promise<HelpModel>}
|
|
||||||
*/
|
|
||||||
async create(createHelpDto: CreateHelpDto, userId: string, ip: string): Promise<HelpModel> {
|
|
||||||
const help = this.helpRepository.create({
|
|
||||||
...createHelpDto,
|
|
||||||
regUserId: userId,
|
|
||||||
regIp: ip,
|
|
||||||
useYn: createHelpDto.useYn || 'Y',
|
|
||||||
});
|
|
||||||
|
|
||||||
return await this.helpRepository.save(help);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 도움말 목록 조회
|
|
||||||
*
|
|
||||||
* @param {FilterHelpDto} filterDto - 필터 조건 (선택)
|
|
||||||
* @returns {Promise<HelpModel[]>}
|
|
||||||
*/
|
|
||||||
async findAll(filterDto?: FilterHelpDto): Promise<HelpModel[]> {
|
|
||||||
const queryBuilder = this.helpRepository.createQueryBuilder('help');
|
|
||||||
|
|
||||||
if (filterDto?.helpCtgry) {
|
|
||||||
queryBuilder.andWhere('help.helpCtgry = :helpCtgry', { helpCtgry: filterDto.helpCtgry });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterDto?.targetNm) {
|
|
||||||
queryBuilder.andWhere('help.targetNm LIKE :targetNm', { targetNm: `%${filterDto.targetNm}%` });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterDto?.useYn) {
|
|
||||||
queryBuilder.andWhere('help.useYn = :useYn', { useYn: filterDto.useYn });
|
|
||||||
}
|
|
||||||
|
|
||||||
return await queryBuilder
|
|
||||||
.orderBy('help.displayOrder', 'ASC')
|
|
||||||
.addOrderBy('help.pkHelpNo', 'DESC')
|
|
||||||
.getMany();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 카테고리별 도움말 조회
|
|
||||||
*
|
|
||||||
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
|
||||||
* @returns {Promise<HelpModel[]>}
|
|
||||||
*/
|
|
||||||
async findByCategory(category: string): Promise<HelpModel[]> {
|
|
||||||
return await this.helpRepository.find({
|
|
||||||
where: {
|
|
||||||
helpCtgry: category,
|
|
||||||
useYn: 'Y',
|
|
||||||
},
|
|
||||||
order: {
|
|
||||||
displayOrder: 'ASC',
|
|
||||||
pkHelpNo: 'DESC',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 대상명의 도움말 조회
|
|
||||||
*
|
|
||||||
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
|
||||||
* @param {string} targetNm - 대상명 (예: PLAG1, 도체중 등)
|
|
||||||
* @returns {Promise<HelpModel>}
|
|
||||||
*/
|
|
||||||
async findByTarget(category: string, targetNm: string): Promise<HelpModel> {
|
|
||||||
const help = await this.helpRepository.findOne({
|
|
||||||
where: {
|
|
||||||
helpCtgry: category,
|
|
||||||
targetNm: targetNm,
|
|
||||||
useYn: 'Y',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!help) {
|
|
||||||
throw new NotFoundException(`도움말을 찾을 수 없습니다. (카테고리: ${category}, 대상: ${targetNm})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return help;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 도움말 번호로 단건 조회
|
|
||||||
*
|
|
||||||
* @param {number} id - 도움말 번호
|
|
||||||
* @returns {Promise<HelpModel>}
|
|
||||||
*/
|
|
||||||
async findOne(id: number): Promise<HelpModel> {
|
|
||||||
const help = await this.helpRepository.findOne({
|
|
||||||
where: { pkHelpNo: id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!help) {
|
|
||||||
throw new NotFoundException(`도움말을 찾을 수 없습니다. (ID: ${id})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return help;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 도움말 수정
|
|
||||||
*
|
|
||||||
* @param {number} id - 도움말 번호
|
|
||||||
* @param {UpdateHelpDto} updateHelpDto - 수정할 데이터
|
|
||||||
* @param {string} userId - 수정자 ID
|
|
||||||
* @param {string} ip - 수정자 IP
|
|
||||||
* @returns {Promise<HelpModel>}
|
|
||||||
*/
|
|
||||||
async update(id: number, updateHelpDto: UpdateHelpDto, userId: string, ip: string): Promise<HelpModel> {
|
|
||||||
const help = await this.findOne(id);
|
|
||||||
|
|
||||||
Object.assign(help, updateHelpDto);
|
|
||||||
help.updtUserId = userId;
|
|
||||||
help.updtIp = ip;
|
|
||||||
|
|
||||||
return await this.helpRepository.save(help);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 도움말 삭제 (soft delete - useYn = 'N')
|
|
||||||
*
|
|
||||||
* @param {number} id - 도움말 번호
|
|
||||||
* @param {string} userId - 삭제자 ID
|
|
||||||
* @param {string} ip - 삭제자 IP
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async remove(id: number, userId: string, ip: string): Promise<void> {
|
|
||||||
const help = await this.findOne(id);
|
|
||||||
|
|
||||||
help.useYn = 'N';
|
|
||||||
help.updtUserId = userId;
|
|
||||||
help.updtIp = ip;
|
|
||||||
|
|
||||||
await this.helpRepository.save(help);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 도움말 영구 삭제 (hard delete)
|
|
||||||
*
|
|
||||||
* @param {number} id - 도움말 번호
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async hardRemove(id: number): Promise<void> {
|
|
||||||
const help = await this.findOne(id);
|
|
||||||
await this.helpRepository.remove(help);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +1,37 @@
|
|||||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
import { MptService } from './mpt.service';
|
import { MptService } from './mpt.service';
|
||||||
import { MptModel } from './entities/mpt.entity';
|
|
||||||
|
|
||||||
@Controller('mpt')
|
@Controller('mpt')
|
||||||
export class MptController {
|
export class MptController {
|
||||||
constructor(private readonly mptService: MptService) {}
|
constructor(private readonly mptService: MptService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MPT 참조값 조회
|
||||||
|
*/
|
||||||
|
@Get('reference')
|
||||||
|
getReferenceValues() {
|
||||||
|
return this.mptService.getReferenceValues();
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll(
|
findAll(
|
||||||
@Query('farmId') farmId?: string,
|
|
||||||
@Query('cowShortNo') cowShortNo?: string,
|
@Query('cowShortNo') cowShortNo?: string,
|
||||||
@Query('cowId') cowId?: string,
|
@Query('cowId') cowId?: string,
|
||||||
) {
|
) {
|
||||||
if (farmId) {
|
|
||||||
return this.mptService.findByFarmId(+farmId);
|
|
||||||
}
|
|
||||||
if (cowId) {
|
if (cowId) {
|
||||||
return this.mptService.findByCowId(cowId);
|
return this.mptService.findByCowId(cowId);
|
||||||
}
|
}
|
||||||
if (cowShortNo) {
|
if (cowShortNo) {
|
||||||
return this.mptService.findByCowShortNo(cowShortNo);
|
return this.mptService.findByCowShortNo(cowShortNo);
|
||||||
}
|
}
|
||||||
return this.mptService.findAll();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 농장별 MPT 통계 조회
|
* 농장별 MPT 통계 조회
|
||||||
* - 카테고리별 정상/주의/위험 개체 수
|
|
||||||
* - 위험 개체 목록
|
|
||||||
*/
|
*/
|
||||||
@Get('statistics/:farmNo')
|
@Get('statistics/:farmNo')
|
||||||
getMptStatistics(@Param('farmNo') farmNo: string) {
|
getMptStatistics(@Param('farmNo') farmNo: string) {
|
||||||
return this.mptService.getMptStatistics(+farmNo);
|
return this.mptService.getMptStatistics(+farmNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
|
||||||
findOne(@Param('id') id: string) {
|
|
||||||
return this.mptService.findOne(+id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
create(@Body() data: Partial<MptModel>) {
|
|
||||||
return this.mptService.create(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('bulk')
|
|
||||||
bulkCreate(@Body() data: Partial<MptModel>[]) {
|
|
||||||
return this.mptService.bulkCreate(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put(':id')
|
|
||||||
update(@Param('id') id: string, @Body() data: Partial<MptModel>) {
|
|
||||||
return this.mptService.update(+id, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
remove(@Param('id') id: string) {
|
|
||||||
return this.mptService.remove(+id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,13 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, IsNull } from 'typeorm';
|
import { Repository, IsNull } from 'typeorm';
|
||||||
import { MptModel } from './entities/mpt.entity';
|
import { MptModel } from './entities/mpt.entity';
|
||||||
|
import {
|
||||||
/**
|
MPT_REFERENCE_RANGES,
|
||||||
* MPT 참조값 범위 (정상/주의/위험 판단 기준)
|
MPT_CATEGORIES,
|
||||||
*/
|
MptReferenceRange,
|
||||||
const MPT_REFERENCE_RANGES: Record<string, { upper: number; lower: number; category: string }> = {
|
MptCategory,
|
||||||
glucose: { lower: 40, upper: 84, category: 'energy' },
|
} from '../common/const/MptReference';
|
||||||
cholesterol: { lower: 74, upper: 252, category: 'energy' },
|
|
||||||
nefa: { lower: 115, upper: 660, category: 'energy' },
|
|
||||||
bcs: { lower: 2.5, upper: 3.5, category: 'energy' },
|
|
||||||
totalProtein: { lower: 6.2, upper: 7.7, category: 'protein' },
|
|
||||||
albumin: { lower: 3.3, upper: 4.3, category: 'protein' },
|
|
||||||
globulin: { lower: 9.1, upper: 36.1, category: 'protein' },
|
|
||||||
agRatio: { lower: 0.1, upper: 0.4, category: 'protein' },
|
|
||||||
bun: { lower: 11.7, upper: 18.9, category: 'protein' },
|
|
||||||
ast: { lower: 47, upper: 92, category: 'liver' },
|
|
||||||
ggt: { lower: 11, upper: 32, category: 'liver' },
|
|
||||||
fattyLiverIdx: { lower: -1.2, upper: 9.9, category: 'liver' },
|
|
||||||
calcium: { lower: 8.1, upper: 10.6, category: 'mineral' },
|
|
||||||
phosphorus: { lower: 6.2, upper: 8.9, category: 'mineral' },
|
|
||||||
caPRatio: { lower: 1.2, upper: 1.3, category: 'mineral' },
|
|
||||||
magnesium: { lower: 1.6, upper: 3.3, category: 'mineral' },
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MPT 통계 응답 DTO
|
* MPT 통계 응답 DTO
|
||||||
@@ -53,22 +37,6 @@ export class MptService {
|
|||||||
private readonly mptRepository: Repository<MptModel>,
|
private readonly mptRepository: Repository<MptModel>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(): Promise<MptModel[]> {
|
|
||||||
return this.mptRepository.find({
|
|
||||||
where: { delDt: IsNull() },
|
|
||||||
relations: ['farm'],
|
|
||||||
order: { testDt: 'DESC' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByFarmId(farmNo: number): Promise<MptModel[]> {
|
|
||||||
return this.mptRepository.find({
|
|
||||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
|
||||||
relations: ['farm'],
|
|
||||||
order: { testDt: 'DESC' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByCowShortNo(cowShortNo: string): Promise<MptModel[]> {
|
async findByCowShortNo(cowShortNo: string): Promise<MptModel[]> {
|
||||||
return this.mptRepository.find({
|
return this.mptRepository.find({
|
||||||
where: { cowShortNo: cowShortNo, delDt: IsNull() },
|
where: { cowShortNo: cowShortNo, delDt: IsNull() },
|
||||||
@@ -85,38 +53,6 @@ export class MptService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: number): Promise<MptModel> {
|
|
||||||
const mpt = await this.mptRepository.findOne({
|
|
||||||
where: { pkMptNo: id, delDt: IsNull() },
|
|
||||||
relations: ['farm'],
|
|
||||||
});
|
|
||||||
if (!mpt) {
|
|
||||||
throw new NotFoundException(`MPT #${id} not found`);
|
|
||||||
}
|
|
||||||
return mpt;
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(data: Partial<MptModel>): Promise<MptModel> {
|
|
||||||
const mpt = this.mptRepository.create(data);
|
|
||||||
return this.mptRepository.save(mpt);
|
|
||||||
}
|
|
||||||
|
|
||||||
async bulkCreate(data: Partial<MptModel>[]): Promise<MptModel[]> {
|
|
||||||
const mpts = this.mptRepository.create(data);
|
|
||||||
return this.mptRepository.save(mpts);
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: number, data: Partial<MptModel>): Promise<MptModel> {
|
|
||||||
await this.findOne(id);
|
|
||||||
await this.mptRepository.update(id, data);
|
|
||||||
return this.findOne(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(id: number): Promise<void> {
|
|
||||||
const mpt = await this.findOne(id);
|
|
||||||
await this.mptRepository.softRemove(mpt);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 농장별 MPT 통계 조회
|
* 농장별 MPT 통계 조회
|
||||||
* - 개체별 최신 검사 결과 기준
|
* - 개체별 최신 검사 결과 기준
|
||||||
@@ -187,7 +123,7 @@ export class MptService {
|
|||||||
const category = ref.category as keyof typeof categoryStatus;
|
const category = ref.category as keyof typeof categoryStatus;
|
||||||
|
|
||||||
// 범위 밖이면 주의
|
// 범위 밖이면 주의
|
||||||
if (value > ref.upper || value < ref.lower) {
|
if (value > ref.upperLimit || value < ref.lowerLimit) {
|
||||||
categoryStatus[category] = 'caution';
|
categoryStatus[category] = 'caution';
|
||||||
|
|
||||||
// 주의 개체 목록에 추가
|
// 주의 개체 목록에 추가
|
||||||
@@ -196,7 +132,7 @@ export class MptService {
|
|||||||
category,
|
category,
|
||||||
itemName: itemKey,
|
itemName: itemKey,
|
||||||
value,
|
value,
|
||||||
status: value > ref.upper ? 'high' : 'low',
|
status: value > ref.upperLimit ? 'high' : 'low',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -239,11 +175,11 @@ export class MptService {
|
|||||||
const refA = MPT_REFERENCE_RANGES[a.itemName];
|
const refA = MPT_REFERENCE_RANGES[a.itemName];
|
||||||
const refB = MPT_REFERENCE_RANGES[b.itemName];
|
const refB = MPT_REFERENCE_RANGES[b.itemName];
|
||||||
const deviationA = a.status === 'high'
|
const deviationA = a.status === 'high'
|
||||||
? (a.value - refA.upper) / (refA.upper - refA.lower)
|
? (a.value - refA.upperLimit) / (refA.upperLimit - refA.lowerLimit)
|
||||||
: (refA.lower - a.value) / (refA.upper - refA.lower);
|
: (refA.lowerLimit - a.value) / (refA.upperLimit - refA.lowerLimit);
|
||||||
const deviationB = b.status === 'high'
|
const deviationB = b.status === 'high'
|
||||||
? (b.value - refB.upper) / (refB.upper - refB.lower)
|
? (b.value - refB.upperLimit) / (refB.upperLimit - refB.lowerLimit)
|
||||||
: (refB.lower - b.value) / (refB.upper - refB.lower);
|
: (refB.lowerLimit - b.value) / (refB.upperLimit - refB.lowerLimit);
|
||||||
return deviationB - deviationA;
|
return deviationB - deviationA;
|
||||||
})
|
})
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
@@ -255,4 +191,14 @@ export class MptService {
|
|||||||
riskyCows: sortedRiskyCows,
|
riskyCows: sortedRiskyCows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MPT 참조값 조회
|
||||||
|
*/
|
||||||
|
getReferenceValues(): { references: Record<string, MptReferenceRange>; categories: MptCategory[] } {
|
||||||
|
return {
|
||||||
|
references: MPT_REFERENCE_RANGES,
|
||||||
|
categories: MPT_CATEGORIES,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
FilterEngineResult,
|
FilterEngineResult,
|
||||||
SortOption,
|
SortOption,
|
||||||
} from './interfaces/filter.interface';
|
} from './interfaces/filter.interface';
|
||||||
|
import { PAGINATION_CONFIG } from '../../common/config/PaginationConfig';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 동적 필터링, 정렬, 페이지네이션을 제공하는 공통 엔진
|
* 동적 필터링, 정렬, 페이지네이션을 제공하는 공통 엔진
|
||||||
@@ -160,11 +161,15 @@
|
|||||||
// 3. 전체 개수 조회 (페이지네이션 전)
|
// 3. 전체 개수 조회 (페이지네이션 전)
|
||||||
const total = await queryBuilder.getCount();
|
const total = await queryBuilder.getCount();
|
||||||
|
|
||||||
// 4. 페이지네이션 적용
|
// 4. 페이지네이션 적용 (기본값: PaginationConfig 사용)
|
||||||
if (options.pagination) {
|
const page = options.pagination?.page ?? PAGINATION_CONFIG.LIMITS.DEFAULT_PAGE;
|
||||||
const { page, limit } = options.pagination;
|
const requestedLimit = options.pagination?.limit ?? PAGINATION_CONFIG.DEFAULTS.COW_LIST;
|
||||||
this.applyPagination(queryBuilder, page, limit);
|
// 최대값 제한 적용
|
||||||
}
|
const limit = Math.min(
|
||||||
|
Math.max(requestedLimit, PAGINATION_CONFIG.LIMITS.MIN),
|
||||||
|
PAGINATION_CONFIG.LIMITS.MAX
|
||||||
|
);
|
||||||
|
this.applyPagination(queryBuilder, page, limit);
|
||||||
|
|
||||||
// 5. 데이터 조회
|
// 5. 데이터 조회
|
||||||
const data = await queryBuilder.getMany();
|
const data = await queryBuilder.getMany();
|
||||||
@@ -173,15 +178,11 @@
|
|||||||
const result: FilterEngineResult<T> = {
|
const result: FilterEngineResult<T> = {
|
||||||
data,
|
data,
|
||||||
total,
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options.pagination) {
|
|
||||||
const { page, limit } = options.pagination;
|
|
||||||
result.page = page;
|
|
||||||
result.limit = limit;
|
|
||||||
result.totalPages = Math.ceil(total / limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
DrawerHeader,
|
DrawerHeader,
|
||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
} from "@/components/ui/drawer"
|
} from "@/components/ui/drawer"
|
||||||
import { ComparisonAveragesDto, FarmTraitComparisonDto, TraitComparisonAveragesDto } from "@/lib/api"
|
import { ComparisonAveragesDto, TraitComparisonAveragesDto } from "@/lib/api/genome.api"
|
||||||
import { Pencil, X, RotateCcw } from 'lucide-react'
|
import { Pencil, X, RotateCcw } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
PolarAngleAxis,
|
PolarAngleAxis,
|
||||||
@@ -26,34 +26,7 @@ import {
|
|||||||
ResponsiveContainer
|
ResponsiveContainer
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query"
|
import { useMediaQuery } from "@/hooks/use-media-query"
|
||||||
|
import { DEFAULT_TRAITS, ALL_TRAITS, TRAIT_CATEGORIES } from "@/constants/traits"
|
||||||
// 디폴트로 표시할 주요 형질 목록
|
|
||||||
const DEFAULT_TRAITS = ['도체중', '등심단면적', '등지방두께', '근내지방도', '체장', '체고', '등심weight']
|
|
||||||
|
|
||||||
// 전체 형질 목록 (35개)
|
|
||||||
const ALL_TRAITS = [
|
|
||||||
// 성장형질 (1개)
|
|
||||||
'12개월령체중',
|
|
||||||
// 경제형질 (4개)
|
|
||||||
'도체중', '등심단면적', '등지방두께', '근내지방도',
|
|
||||||
// 체형형질 (10개)
|
|
||||||
'체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
|
||||||
// 부위별무게 (10개)
|
|
||||||
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
|
||||||
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
|
||||||
// 부위별비율 (10개)
|
|
||||||
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
|
||||||
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
|
||||||
]
|
|
||||||
|
|
||||||
// 형질 카테고리 (백엔드 API와 일치: 성장, 생산, 체형, 무게, 비율)
|
|
||||||
const TRAIT_CATEGORIES: Record<string, string[]> = {
|
|
||||||
'성장': ['12개월령체중'],
|
|
||||||
'생산': ['도체중', '등심단면적', '등지방두께', '근내지방도'],
|
|
||||||
'체형': ['체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위'],
|
|
||||||
'무게': ['안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight', '우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight'],
|
|
||||||
'비율': ['안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate', '우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate'],
|
|
||||||
}
|
|
||||||
|
|
||||||
// 형질명 표시 (전체 이름)
|
// 형질명 표시 (전체 이름)
|
||||||
const TRAIT_SHORT_NAMES: Record<string, string> = {
|
const TRAIT_SHORT_NAMES: Record<string, string> = {
|
||||||
@@ -121,7 +94,6 @@ interface CategoryEvaluationCardProps {
|
|||||||
farmAvgZ: number
|
farmAvgZ: number
|
||||||
allTraits?: TraitData[]
|
allTraits?: TraitData[]
|
||||||
cowNo?: string
|
cowNo?: string
|
||||||
traitAverages?: FarmTraitComparisonDto | null // 형질별 평균 비교 데이터 (기존)
|
|
||||||
hideTraitCards?: boolean // 형질 카드 숨김 여부
|
hideTraitCards?: boolean // 형질 카드 숨김 여부
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,11 +119,10 @@ export function CategoryEvaluationCard({
|
|||||||
farmAvgZ,
|
farmAvgZ,
|
||||||
allTraits = [],
|
allTraits = [],
|
||||||
cowNo,
|
cowNo,
|
||||||
traitAverages,
|
|
||||||
hideTraitCards = false,
|
hideTraitCards = false,
|
||||||
}: CategoryEvaluationCardProps) {
|
}: CategoryEvaluationCardProps) {
|
||||||
// 차트에 표시할 형질 목록 (커스텀 가능)
|
// 차트에 표시할 형질 목록 (커스텀 가능)
|
||||||
const [chartTraits, setChartTraits] = useState<string[]>(DEFAULT_TRAITS)
|
const [chartTraits, setChartTraits] = useState<string[]>([...DEFAULT_TRAITS])
|
||||||
|
|
||||||
// 형질 추가 모달/드로어 상태
|
// 형질 추가 모달/드로어 상태
|
||||||
const [isTraitSelectorOpen, setIsTraitSelectorOpen] = useState(false)
|
const [isTraitSelectorOpen, setIsTraitSelectorOpen] = useState(false)
|
||||||
@@ -178,7 +149,7 @@ export function CategoryEvaluationCard({
|
|||||||
|
|
||||||
// 기본값으로 초기화
|
// 기본값으로 초기화
|
||||||
const resetToDefault = () => {
|
const resetToDefault = () => {
|
||||||
setChartTraits(DEFAULT_TRAITS)
|
setChartTraits([...DEFAULT_TRAITS])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 폴리곤 차트용 데이터 생성 (선택된 형질 기반) - 보은군, 농가, 이 개체 비교
|
// 폴리곤 차트용 데이터 생성 (선택된 형질 기반) - 보은군, 농가, 이 개체 비교
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { apiClient } from "@/lib/api"
|
import apiClient from "@/lib/api-client"
|
||||||
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
|
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
|
||||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
import { useFilterStore } from "@/store/filter-store"
|
||||||
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
|
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
|
||||||
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
||||||
|
import { ALL_TRAITS } from "@/constants/traits"
|
||||||
|
|
||||||
// 분포 데이터 타입
|
// 분포 데이터 타입
|
||||||
interface DistributionBin {
|
interface DistributionBin {
|
||||||
@@ -115,7 +116,7 @@ export function GenomeIntegratedComparison({
|
|||||||
}
|
}
|
||||||
//===========================================================================================
|
//===========================================================================================
|
||||||
|
|
||||||
const { filters } = useGlobalFilter()
|
const { filters } = useFilterStore()
|
||||||
const { selectedYear } = useAnalysisYear()
|
const { selectedYear } = useAnalysisYear()
|
||||||
const [stats, setStats] = useState<IntegratedStats | null>(null)
|
const [stats, setStats] = useState<IntegratedStats | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -132,22 +133,6 @@ export function GenomeIntegratedComparison({
|
|||||||
}[]>([])
|
}[]>([])
|
||||||
const [trendLoading, setTrendLoading] = useState(true)
|
const [trendLoading, setTrendLoading] = useState(true)
|
||||||
|
|
||||||
// 전체 35개 형질 목록 (filter.types.ts의 traitWeights 키와 동일)
|
|
||||||
const ALL_TRAITS = [
|
|
||||||
// 성장형질 (1개)
|
|
||||||
'12개월령체중',
|
|
||||||
// 경제형질 (4개)
|
|
||||||
'도체중', '등심단면적', '등지방두께', '근내지방도',
|
|
||||||
// 체형형질 (10개)
|
|
||||||
'체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
|
||||||
// 부위별무게 (10개)
|
|
||||||
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
|
||||||
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
|
||||||
// 부위별비율 (10개)
|
|
||||||
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
|
||||||
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
|
||||||
]
|
|
||||||
|
|
||||||
// 형질 조건 생성 (형질명 + 가중치)
|
// 형질 조건 생성 (형질명 + 가중치)
|
||||||
const getTraitConditions = () => {
|
const getTraitConditions = () => {
|
||||||
const selected = Object.entries(filters.traitWeights)
|
const selected = Object.entries(filters.traitWeights)
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ import {
|
|||||||
YAxis
|
YAxis
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
|
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
|
||||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
import { useFilterStore } from "@/store/filter-store"
|
||||||
|
import { NEGATIVE_TRAITS } from "@/constants/traits"
|
||||||
// 낮을수록 좋은 형질 (부호 반전 필요)
|
|
||||||
const NEGATIVE_TRAITS = ['등지방두께']
|
|
||||||
|
|
||||||
// 카테고리 색상 (모던 & 다이나믹 - 생동감 있는 색상)
|
// 카테고리 색상 (모던 & 다이나믹 - 생동감 있는 색상)
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
@@ -188,7 +186,7 @@ export function NormalDistributionChart({
|
|||||||
chartFilterTrait: externalChartFilterTrait,
|
chartFilterTrait: externalChartFilterTrait,
|
||||||
onChartFilterTraitChange
|
onChartFilterTraitChange
|
||||||
}: NormalDistributionChartProps) {
|
}: NormalDistributionChartProps) {
|
||||||
const { filters } = useGlobalFilter()
|
const { filters } = useFilterStore()
|
||||||
|
|
||||||
// 필터에서 고정된 첫 번째 형질 (없으면 첫 번째 선택된 형질, 없으면 '도체중')
|
// 필터에서 고정된 첫 번째 형질 (없으면 첫 번째 선택된 형질, 없으면 '도체중')
|
||||||
const firstPinnedTrait = filters.pinnedTraits?.[0] || selectedTraitData[0]?.name || '도체중'
|
const firstPinnedTrait = filters.pinnedTraits?.[0] || selectedTraitData[0]?.name || '도체중'
|
||||||
|
|||||||
@@ -2,12 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { DEFAULT_TRAITS, NEGATIVE_TRAITS } from "@/constants/traits"
|
||||||
// 기본 7개 형질
|
|
||||||
const DEFAULT_TRAITS = ['도체중', '등심단면적', '등지방두께', '근내지방도', '체장', '체고', '등심weight']
|
|
||||||
|
|
||||||
// 낮을수록 좋은 형질 (부호 반전 색상 적용)
|
|
||||||
const NEGATIVE_TRAITS = ['등지방두께']
|
|
||||||
|
|
||||||
// 형질명 표시 (전체 이름)
|
// 형질명 표시 (전체 이름)
|
||||||
const TRAIT_SHORT_NAMES: Record<string, string> = {
|
const TRAIT_SHORT_NAMES: Record<string, string> = {
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
import { useFilterStore } from "@/store/filter-store"
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query"
|
import { useMediaQuery } from "@/hooks/use-media-query"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
import { ComparisonAveragesDto, cowApi, geneApi, GeneDetail, genomeApi, GenomeRequestDto, TraitComparisonAveragesDto, mptApi } from "@/lib/api"
|
import { cowApi } from "@/lib/api/cow.api"
|
||||||
|
import { geneApi, GeneDetail } from "@/lib/api/gene.api"
|
||||||
|
import { genomeApi, ComparisonAveragesDto, GenomeRequestDto, TraitComparisonAveragesDto } from "@/lib/api/genome.api"
|
||||||
|
import { mptApi } from "@/lib/api/mpt.api"
|
||||||
import { getInvalidMessage, getInvalidReason, isExcludedCow, isValidGenomeAnalysis } from "@/lib/utils/genome-analysis-config"
|
import { getInvalidMessage, getInvalidReason, isExcludedCow, isValidGenomeAnalysis } from "@/lib/utils/genome-analysis-config"
|
||||||
import { CowDetail } from "@/types/cow.types"
|
import { CowDetail } from "@/types/cow.types"
|
||||||
import { GenomeTrait } from "@/types/genome.types"
|
import { GenomeTrait } from "@/types/genome.types"
|
||||||
@@ -145,7 +148,7 @@ export default function CowOverviewPage() {
|
|||||||
const cowNo = params.cowNo as string
|
const cowNo = params.cowNo as string
|
||||||
const from = searchParams.get('from')
|
const from = searchParams.get('from')
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { filters } = useGlobalFilter()
|
const { filters } = useFilterStore()
|
||||||
const isMobile = useMediaQuery("(max-width: 640px)")
|
const isMobile = useMediaQuery("(max-width: 640px)")
|
||||||
|
|
||||||
const [cow, setCow] = useState<CowDetail | null>(null)
|
const [cow, setCow] = useState<CowDetail | null>(null)
|
||||||
|
|||||||
@@ -3,26 +3,20 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { mptApi, MptDto } from "@/lib/api"
|
import { mptApi, MptDto, MptReferenceRange, MptCategory } from "@/lib/api/mpt.api"
|
||||||
import { Activity, CheckCircle2, XCircle } from 'lucide-react'
|
import { Activity, CheckCircle2, XCircle } from 'lucide-react'
|
||||||
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
||||||
import { CowDetail } from "@/types/cow.types"
|
import { CowDetail } from "@/types/cow.types"
|
||||||
import { GenomeRequestDto } from "@/lib/api"
|
import { GenomeRequestDto } from "@/lib/api/genome.api"
|
||||||
import { MPT_REFERENCE_RANGES } from "@/constants/mpt-reference"
|
|
||||||
|
|
||||||
// 혈액화학검사 카테고리별 항목
|
|
||||||
const MPT_CATEGORIES = [
|
|
||||||
{ name: '에너지', items: ['glucose', 'cholesterol', 'nefa', 'bcs'], color: 'bg-muted/50' },
|
|
||||||
{ name: '단백질', items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'], color: 'bg-muted/50' },
|
|
||||||
{ name: '간기능', items: ['ast', 'ggt', 'fattyLiverIdx'], color: 'bg-muted/50' },
|
|
||||||
{ name: '미네랄', items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium'], color: 'bg-muted/50' },
|
|
||||||
{ name: '별도', items: ['creatine'], color: 'bg-muted/50' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 측정값 상태 판정: 안전(safe) / 주의(caution)
|
// 측정값 상태 판정: 안전(safe) / 주의(caution)
|
||||||
function getMptValueStatus(key: string, value: number | null): 'safe' | 'caution' | 'unknown' {
|
function getMptValueStatus(
|
||||||
|
key: string,
|
||||||
|
value: number | null,
|
||||||
|
references: Record<string, MptReferenceRange>
|
||||||
|
): 'safe' | 'caution' | 'unknown' {
|
||||||
if (value === null || value === undefined) return 'unknown'
|
if (value === null || value === undefined) return 'unknown'
|
||||||
const ref = MPT_REFERENCE_RANGES[key]
|
const ref = references[key]
|
||||||
if (!ref || ref.lowerLimit === null || ref.upperLimit === null) return 'unknown'
|
if (!ref || ref.lowerLimit === null || ref.upperLimit === null) return 'unknown'
|
||||||
// 하한값 ~ 상한값 사이면 안전, 그 외 주의
|
// 하한값 ~ 상한값 사이면 안전, 그 외 주의
|
||||||
if (value >= ref.lowerLimit && value <= ref.upperLimit) return 'safe'
|
if (value >= ref.lowerLimit && value <= ref.upperLimit) return 'safe'
|
||||||
@@ -41,6 +35,25 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
|||||||
const [mptData, setMptData] = useState<MptDto[]>([])
|
const [mptData, setMptData] = useState<MptDto[]>([])
|
||||||
const [selectedMpt, setSelectedMpt] = useState<MptDto | null>(null)
|
const [selectedMpt, setSelectedMpt] = useState<MptDto | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [references, setReferences] = useState<Record<string, MptReferenceRange>>({})
|
||||||
|
const [categories, setCategories] = useState<MptCategory[]>([])
|
||||||
|
const [refLoading, setRefLoading] = useState(true)
|
||||||
|
|
||||||
|
// 참조값 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadReference = async () => {
|
||||||
|
try {
|
||||||
|
const data = await mptApi.getReferenceValues()
|
||||||
|
setReferences(data.references)
|
||||||
|
setCategories(data.categories)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MPT 참조값 로드 실패:', error)
|
||||||
|
} finally {
|
||||||
|
setRefLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadReference()
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchMptData = async () => {
|
const fetchMptData = async () => {
|
||||||
@@ -63,7 +76,7 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
|||||||
fetchMptData()
|
fetchMptData()
|
||||||
}, [cowNo])
|
}, [cowNo])
|
||||||
|
|
||||||
if (loading) {
|
if (loading || refLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -245,11 +258,11 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{MPT_CATEGORIES.map((category) => (
|
{categories.map((category) => (
|
||||||
category.items.map((itemKey, itemIdx) => {
|
category.items.map((itemKey, itemIdx) => {
|
||||||
const ref = MPT_REFERENCE_RANGES[itemKey]
|
const ref = references[itemKey]
|
||||||
const value = selectedMpt ? (selectedMpt[itemKey as keyof MptDto] as number | null) : null
|
const value = selectedMpt ? (selectedMpt[itemKey as keyof MptDto] as number | null) : null
|
||||||
const status = getMptValueStatus(itemKey, value)
|
const status = getMptValueStatus(itemKey, value, references)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={itemKey} className="border-b border-border hover:bg-muted/30">
|
<tr key={itemKey} className="border-b border-border hover:bg-muted/30">
|
||||||
|
|||||||
@@ -8,15 +8,29 @@ import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { cowApi, reproApi } from "@/lib/api"
|
import { cowApi } from "@/lib/api/cow.api"
|
||||||
|
import { mptApi, MptDto, MptReferenceRange } from "@/lib/api/mpt.api"
|
||||||
import { CowDetail } from "@/types/cow.types"
|
import { CowDetail } from "@/types/cow.types"
|
||||||
import { ReproMpt } from "@/types/mpt.types"
|
|
||||||
import { Activity, AlertCircle, CheckCircle } from "lucide-react"
|
import { Activity, AlertCircle, CheckCircle } from "lucide-react"
|
||||||
import { CowNavigation } from "../_components/navigation"
|
import { CowNavigation } from "../_components/navigation"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
import { MPT_REFERENCE_RANGES, isWithinRange } from "@/constants/mpt-reference"
|
|
||||||
import { AuthGuard } from "@/components/auth/auth-guard"
|
import { AuthGuard } from "@/components/auth/auth-guard"
|
||||||
|
|
||||||
|
// 측정값이 정상 범위 내인지 확인
|
||||||
|
function isWithinRange(
|
||||||
|
value: number,
|
||||||
|
itemKey: string,
|
||||||
|
references: Record<string, MptReferenceRange>
|
||||||
|
): 'normal' | 'high' | 'low' | 'unknown' {
|
||||||
|
const reference = references[itemKey]
|
||||||
|
if (!reference || reference.upperLimit === null || reference.lowerLimit === null) {
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
if (value > reference.upperLimit) return 'high'
|
||||||
|
if (value < reference.lowerLimit) return 'low'
|
||||||
|
return 'normal'
|
||||||
|
}
|
||||||
|
|
||||||
export default function ReproductionPage() {
|
export default function ReproductionPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -26,8 +40,25 @@ export default function ReproductionPage() {
|
|||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const [cow, setCow] = useState<CowDetail | null>(null)
|
const [cow, setCow] = useState<CowDetail | null>(null)
|
||||||
const [reproMpt, setReproMpt] = useState<ReproMpt[]>([])
|
const [mptData, setMptData] = useState<MptDto[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [references, setReferences] = useState<Record<string, MptReferenceRange>>({})
|
||||||
|
const [refLoading, setRefLoading] = useState(true)
|
||||||
|
|
||||||
|
// 참조값 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadReference = async () => {
|
||||||
|
try {
|
||||||
|
const data = await mptApi.getReferenceValues()
|
||||||
|
setReferences(data.references)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MPT 참조값 로드 실패:', error)
|
||||||
|
} finally {
|
||||||
|
setRefLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadReference()
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -47,8 +78,8 @@ export default function ReproductionPage() {
|
|||||||
// 암소인 경우만 MPT 정보 조회
|
// 암소인 경우만 MPT 정보 조회
|
||||||
if (cowData.cowSex === 'F') {
|
if (cowData.cowSex === 'F') {
|
||||||
try {
|
try {
|
||||||
const mptData = await reproApi.findMptByCowNo(cowNo)
|
const data = await mptApi.findByCowId(cowNo)
|
||||||
setReproMpt(mptData)
|
setMptData(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('MPT 정보 조회 실패:', err)
|
console.error('MPT 정보 조회 실패:', err)
|
||||||
}
|
}
|
||||||
@@ -69,7 +100,7 @@ export default function ReproductionPage() {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}, [cowNo, toast])
|
}, [cowNo, toast])
|
||||||
|
|
||||||
if (loading || !cow) {
|
if (loading || refLoading || !cow) {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
@@ -125,27 +156,27 @@ export default function ReproductionPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MPT 데이터 정리
|
// MPT 데이터 정리
|
||||||
const mptItems = reproMpt.length > 0 ? [
|
const mptItems = mptData.length > 0 ? [
|
||||||
{ name: '글루코스', value: reproMpt[0].bloodSugar, fieldName: 'bloodSugar' },
|
{ name: '혈당', value: mptData[0].glucose, fieldName: 'glucose' },
|
||||||
{ name: '콜레스테롤', value: reproMpt[0].cholesterol, fieldName: 'cholesterol' },
|
{ name: '콜레스테롤', value: mptData[0].cholesterol, fieldName: 'cholesterol' },
|
||||||
{ name: 'NEFA', value: reproMpt[0].nefa, fieldName: 'nefa' },
|
{ name: 'NEFA', value: mptData[0].nefa, fieldName: 'nefa' },
|
||||||
{ name: '알부민', value: reproMpt[0].albumin, fieldName: 'albumin' },
|
{ name: '알부민', value: mptData[0].albumin, fieldName: 'albumin' },
|
||||||
{ name: '총글로불린', value: reproMpt[0].totalGlobulin, fieldName: 'totalGlobulin' },
|
{ name: '총글로불린', value: mptData[0].globulin, fieldName: 'globulin' },
|
||||||
{ name: 'A/G', value: reproMpt[0].agRatio, fieldName: 'agRatio' },
|
{ name: 'A/G', value: mptData[0].agRatio, fieldName: 'agRatio' },
|
||||||
{ name: '요소태질소(BUN)', value: reproMpt[0].bun, fieldName: 'bun' },
|
{ name: '요소태질소(BUN)', value: mptData[0].bun, fieldName: 'bun' },
|
||||||
{ name: 'AST', value: reproMpt[0].ast, fieldName: 'ast' },
|
{ name: 'AST', value: mptData[0].ast, fieldName: 'ast' },
|
||||||
{ name: 'GGT', value: reproMpt[0].ggt, fieldName: 'ggt' },
|
{ name: 'GGT', value: mptData[0].ggt, fieldName: 'ggt' },
|
||||||
{ name: '지방간 지수', value: reproMpt[0].fattyLiverIndex, fieldName: 'fattyLiverIndex' },
|
{ name: '지방간 지수', value: mptData[0].fattyLiverIdx, fieldName: 'fattyLiverIdx' },
|
||||||
{ name: '칼슘', value: reproMpt[0].calcium, fieldName: 'calcium' },
|
{ name: '칼슘', value: mptData[0].calcium, fieldName: 'calcium' },
|
||||||
{ name: '인', value: reproMpt[0].phosphorus, fieldName: 'phosphorus' },
|
{ name: '인', value: mptData[0].phosphorus, fieldName: 'phosphorus' },
|
||||||
{ name: '칼슘/인', value: reproMpt[0].caPRatio, fieldName: 'caPRatio' },
|
{ name: '칼슘/인', value: mptData[0].caPRatio, fieldName: 'caPRatio' },
|
||||||
{ name: '마그네슘', value: reproMpt[0].magnesium, fieldName: 'magnesium' },
|
{ name: '마그네슘', value: mptData[0].magnesium, fieldName: 'magnesium' },
|
||||||
{ name: '크레아틴', value: reproMpt[0].creatine, fieldName: 'creatine' },
|
{ name: '크레아틴', value: mptData[0].creatine, fieldName: 'creatine' },
|
||||||
] : []
|
] : []
|
||||||
|
|
||||||
const normalItems = mptItems.filter(item => {
|
const normalItems = mptItems.filter(item => {
|
||||||
if (item.value === undefined || item.value === null) return false
|
if (item.value === undefined || item.value === null) return false
|
||||||
return isWithinRange(item.value, item.fieldName) === 'normal'
|
return isWithinRange(item.value, item.fieldName, references) === 'normal'
|
||||||
})
|
})
|
||||||
|
|
||||||
const healthScore = mptItems.length > 0 ? Math.round((normalItems.length / mptItems.length) * 100) : 0
|
const healthScore = mptItems.length > 0 ? Math.round((normalItems.length / mptItems.length) * 100) : 0
|
||||||
@@ -173,7 +204,7 @@ export default function ReproductionPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MPT 혈액검사 결과 */}
|
{/* MPT 혈액검사 결과 */}
|
||||||
{reproMpt.length > 0 ? (
|
{mptData.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -202,16 +233,16 @@ export default function ReproductionPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>MPT 혈액검사 상세</CardTitle>
|
<CardTitle>MPT 혈액검사 상세</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
검사일: {reproMpt[0].reproMptDate
|
검사일: {mptData[0].testDt
|
||||||
? new Date(reproMpt[0].reproMptDate).toLocaleDateString('ko-KR')
|
? new Date(mptData[0].testDt).toLocaleDateString('ko-KR')
|
||||||
: '정보 없음'}
|
: '정보 없음'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
{mptItems.map((item, idx) => {
|
{mptItems.map((item, idx) => {
|
||||||
const isNormal = item.value !== undefined && item.value !== null && isWithinRange(item.value, item.fieldName) === 'normal'
|
const isNormal = item.value !== undefined && item.value !== null && isWithinRange(item.value, item.fieldName, references) === 'normal'
|
||||||
const reference = MPT_REFERENCE_RANGES[item.fieldName as keyof typeof MPT_REFERENCE_RANGES]
|
const reference = references[item.fieldName]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={idx} className={`p-3 rounded-lg border ${isNormal ? 'bg-green-50 border-green-200' : 'bg-orange-50 border-orange-200'}`}>
|
<div key={idx} className={`p-3 rounded-lg border ${isNormal ? 'bg-green-50 border-green-200' : 'bg-orange-50 border-orange-200'}`}>
|
||||||
@@ -233,13 +264,6 @@ export default function ReproductionPage() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{reproMpt[0].reproMptNote && (
|
|
||||||
<div className="mt-6 p-4 bg-muted rounded-lg">
|
|
||||||
<div className="text-sm font-semibold mb-2">검사 메모</div>
|
|
||||||
<p className="text-sm text-muted-foreground">{reproMpt[0].reproMptNote}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import { Checkbox } from "@/components/ui/checkbox"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { cowApi } from "@/lib/api"
|
import { cowApi } from "@/lib/api/cow.api"
|
||||||
import { useAuthStore } from "@/store/auth-store"
|
import { useAuthStore } from "@/store/auth-store"
|
||||||
import { useGlobalFilter, GlobalFilterProvider } from "@/contexts/GlobalFilterContext"
|
import { useFilterStore } from "@/store/filter-store"
|
||||||
import { AnalysisYearProvider } from "@/contexts/AnalysisYearContext"
|
import { AnalysisYearProvider } from "@/contexts/AnalysisYearContext"
|
||||||
import { AuthGuard } from "@/components/auth/auth-guard"
|
import { AuthGuard } from "@/components/auth/auth-guard"
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ function MyCowContent() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
const { filters, isLoading: isFilterLoading } = useGlobalFilter()
|
const { filters, isLoading: isFilterLoading } = useFilterStore()
|
||||||
const [markerTypes, setMarkerTypes] = useState<Record<string, string>>({}) // 마커명 → 타입(QTY/QLT) 매핑
|
const [markerTypes, setMarkerTypes] = useState<Record<string, string>>({}) // 마커명 → 타입(QTY/QLT) 매핑
|
||||||
|
|
||||||
// 로컬 필터 상태 (검색, 랭킹모드, 정렬)
|
// 로컬 필터 상태 (검색, 랭킹모드, 정렬)
|
||||||
@@ -264,7 +264,7 @@ function MyCowContent() {
|
|||||||
},
|
},
|
||||||
rankingOptions
|
rankingOptions
|
||||||
}
|
}
|
||||||
|
// 백엔드 ranking API 호출
|
||||||
const response = await cowApi.getRanking(rankingRequest)
|
const response = await cowApi.getRanking(rankingRequest)
|
||||||
|
|
||||||
// ==========================================================================================================
|
// ==========================================================================================================
|
||||||
@@ -952,7 +952,7 @@ function MyCowContent() {
|
|||||||
<th className="cow-table-header" style={{ width: '60px' }}>성별</th>
|
<th className="cow-table-header" style={{ width: '60px' }}>성별</th>
|
||||||
<th className="cow-table-header" style={{ width: '100px' }}>모개체번호</th>
|
<th className="cow-table-header" style={{ width: '100px' }}>모개체번호</th>
|
||||||
<th className="cow-table-header" style={{ width: '90px' }}>아비 KPN</th>
|
<th className="cow-table-header" style={{ width: '90px' }}>아비 KPN</th>
|
||||||
<th className="cow-table-header" style={{ width: '80px' }}>
|
<th className="cow-table-header" style={{ width: '100px', whiteSpace: 'nowrap' }}>
|
||||||
{analysisFilter === 'mptOnly' ? '월령(검사일)' : '월령(분석일)'}
|
{analysisFilter === 'mptOnly' ? '월령(검사일)' : '월령(분석일)'}
|
||||||
</th>
|
</th>
|
||||||
<th className="cow-table-header" style={{ width: '90px' }}>
|
<th className="cow-table-header" style={{ width: '90px' }}>
|
||||||
@@ -1505,11 +1505,11 @@ function MyCowContent() {
|
|||||||
export default function MyCowPage() {
|
export default function MyCowPage() {
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<GlobalFilterProvider>
|
|
||||||
<AnalysisYearProvider>
|
<AnalysisYearProvider>
|
||||||
<MyCowContent />
|
<MyCowContent />
|
||||||
</AnalysisYearProvider>
|
</AnalysisYearProvider>
|
||||||
</GlobalFilterProvider>
|
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { apiClient, farmApi } from "@/lib/api"
|
import apiClient from "@/lib/api-client"
|
||||||
|
import { farmApi } from "@/lib/api/farm.api"
|
||||||
import { DashboardStatsDto, FarmRegionRankingDto, YearlyTraitTrendDto, genomeApi } from "@/lib/api/genome.api"
|
import { DashboardStatsDto, FarmRegionRankingDto, YearlyTraitTrendDto, genomeApi } from "@/lib/api/genome.api"
|
||||||
import { mptApi, MptStatisticsDto } from "@/lib/api/mpt.api"
|
import { mptApi, MptStatisticsDto } from "@/lib/api/mpt.api"
|
||||||
import { useAuthStore } from "@/store/auth-store"
|
import { useAuthStore } from "@/store/auth-store"
|
||||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
import { useFilterStore } from "@/store/filter-store"
|
||||||
|
import { TRAIT_CATEGORIES, NEGATIVE_TRAITS } from "@/constants/traits"
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -50,22 +52,10 @@ import {
|
|||||||
YAxis
|
YAxis
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
|
|
||||||
// 카테고리별 형질 목록 (백엔드 TRAIT_CATEGORY_MAP과 일치)
|
|
||||||
const TRAIT_CATEGORIES: Record<string, string[]> = {
|
|
||||||
'성장': ['12개월령체중'],
|
|
||||||
'생산': ['도체중', '등심단면적', '등지방두께', '근내지방도'],
|
|
||||||
'체형': ['체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '곤폭', '좌골폭', '흉위'],
|
|
||||||
'무게': ['안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight', '우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight'],
|
|
||||||
'비율': ['안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate', '우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate'],
|
|
||||||
}
|
|
||||||
|
|
||||||
// 낮을수록 좋은 형질 (부호 반전 필요)
|
|
||||||
const NEGATIVE_TRAITS = ['등지방두께']
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
const { filters } = useGlobalFilter()
|
const { filters } = useFilterStore()
|
||||||
const [farmNo, setFarmNo] = useState<number | null>(null)
|
const [farmNo, setFarmNo] = useState<number | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [stats, setStats] = useState<DashboardStatsDto | null>(null)
|
const [stats, setStats] = useState<DashboardStatsDto | null>(null)
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { User, Mail, ArrowLeft } from "lucide-react";
|
|
||||||
|
|
||||||
// 시안 1: 현재 디자인
|
|
||||||
function FindIdDesign1() {
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
|
||||||
<h1 className="text-2xl font-bold">아이디 찾기</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
가입 시 등록한 이름과 이메일을 입력해주세요
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이름</label>
|
|
||||||
<Input placeholder="홍길동" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이메일</label>
|
|
||||||
<Input type="email" placeholder="example@email.com" />
|
|
||||||
<p className="text-xs text-gray-500">가입 시 등록한 이메일 주소를 입력해주세요</p>
|
|
||||||
</div>
|
|
||||||
<Button className="w-full">인증번호 발송</Button>
|
|
||||||
<Button variant="outline" className="w-full border-2 border-primary text-primary">로그인</Button>
|
|
||||||
<div className="relative my-2">
|
|
||||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t" /></div>
|
|
||||||
<div className="relative flex justify-center text-xs"><span className="bg-white px-2 text-gray-500">계정이 없으신가요?</span></div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full border-2 border-primary text-primary">회원가입</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 2: 아이콘 + 간결한 레이아웃
|
|
||||||
function FindIdDesign2() {
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
|
||||||
<h1 className="text-2xl font-bold">아이디 찾기</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
가입 시 등록한 정보로 아이디를 찾을 수 있습니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이름</label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<Input placeholder="이름을 입력하세요" className="pl-10 h-11" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이메일</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<Input type="email" placeholder="이메일을 입력하세요" className="pl-10 h-11" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button className="w-full h-11">인증번호 발송</Button>
|
|
||||||
<div className="relative my-2">
|
|
||||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t" /></div>
|
|
||||||
<div className="relative flex justify-center text-xs"><span className="bg-white px-2 text-gray-500">또는</span></div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full h-11 border-2 border-primary text-primary">로그인으로 돌아가기</Button>
|
|
||||||
<div className="text-center">
|
|
||||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">비밀번호를 잊으셨나요?</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 3: 뒤로가기 버튼 + 깔끔한 구조
|
|
||||||
function FindIdDesign3() {
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<a href="#" className="flex items-center gap-1 text-sm text-gray-500 hover:text-primary mb-2">
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
로그인으로 돌아가기
|
|
||||||
</a>
|
|
||||||
<div className="flex flex-col gap-1 mb-2">
|
|
||||||
<h1 className="text-2xl font-bold">아이디 찾기</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
가입 시 등록한 이름과 이메일을 입력해주세요
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이름</label>
|
|
||||||
<Input placeholder="이름을 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이메일</label>
|
|
||||||
<Input type="email" placeholder="이메일을 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<Button className="w-full h-11 mt-2">인증번호 발송</Button>
|
|
||||||
<div className="flex items-center justify-center gap-4 text-sm text-gray-500 mt-2">
|
|
||||||
<a href="#" className="hover:text-primary">비밀번호 찾기</a>
|
|
||||||
<span>|</span>
|
|
||||||
<a href="#" className="hover:text-primary">회원가입</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 4: 로그인과 통일된 스타일 (추천)
|
|
||||||
function FindIdDesign4() {
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
|
||||||
<h1 className="text-2xl font-bold">아이디 찾기</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
가입 시 등록한 정보를 입력해주세요
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이름</label>
|
|
||||||
<Input placeholder="이름을 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-sm font-medium">이메일</label>
|
|
||||||
<a href="#" className="text-xs text-primary hover:underline">비밀번호 찾기</a>
|
|
||||||
</div>
|
|
||||||
<Input type="email" placeholder="이메일을 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<Button className="w-full h-11">인증번호 발송</Button>
|
|
||||||
<div className="relative my-2">
|
|
||||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t" /></div>
|
|
||||||
<div className="relative flex justify-center text-xs"><span className="bg-white px-2 text-gray-500">또는</span></div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full h-11 border-2 border-primary text-primary">로그인</Button>
|
|
||||||
<div className="text-center">
|
|
||||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">계정이 없으신가요? 회원가입</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FindIdDemo() {
|
|
||||||
const designs = [
|
|
||||||
{ id: "current", name: "현재", description: "현재 적용된 디자인", features: ["기존 레이아웃"], component: FindIdDesign1 },
|
|
||||||
{ id: "icon", name: "시안 2", description: "아이콘 + 간결한 레이아웃", features: ["입력 필드 아이콘", "간결한 하단 링크"], component: FindIdDesign2 },
|
|
||||||
{ id: "back", name: "시안 3", description: "뒤로가기 버튼 + 좌측 정렬 제목", features: ["뒤로가기 버튼", "좌측 정렬"], component: FindIdDesign3 },
|
|
||||||
{ id: "unified", name: "시안 4", description: "로그인과 통일된 스타일 (추천)", features: ["로그인 스타일 통일", "비밀번호 찾기 위치"], component: FindIdDesign4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-100 p-8">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">아이디 찾기 페이지 디자인 시안</h1>
|
|
||||||
<p className="text-gray-600 mt-2">각 탭을 클릭하여 다른 디자인 시안을 비교해보세요</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="current" className="space-y-6">
|
|
||||||
<TabsList className="grid grid-cols-4 w-full h-auto p-1">
|
|
||||||
{designs.map((design) => (
|
|
||||||
<TabsTrigger key={design.id} value={design.id} className="py-3 text-xs sm:text-sm data-[state=active]:bg-primary data-[state=active]:text-white">
|
|
||||||
{design.name}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{designs.map((design) => (
|
|
||||||
<TabsContent key={design.id} value={design.id} className="space-y-4">
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">{design.name}: {design.description}</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{design.features.map((feature, idx) => (
|
|
||||||
<Badge key={idx} variant="secondary">{feature}</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<design.component />
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { User, Mail, ArrowLeft, Eye, EyeOff } from "lucide-react";
|
|
||||||
|
|
||||||
// 시안 1: 현재 디자인
|
|
||||||
function FindPwDesign1() {
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
|
||||||
<h1 className="text-2xl font-bold">비밀번호 찾기</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
아이디와 이메일을 입력해주세요
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">아이디</label>
|
|
||||||
<Input placeholder="아이디를 입력하세요" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이메일</label>
|
|
||||||
<Input type="email" placeholder="이메일 주소를 입력해주세요" />
|
|
||||||
<p className="text-xs text-gray-500">가입 시 등록한 이메일 주소를 입력해주세요</p>
|
|
||||||
</div>
|
|
||||||
<Button className="w-full">인증번호 발송</Button>
|
|
||||||
<Button variant="outline" className="w-full border-2 border-primary text-primary">로그인</Button>
|
|
||||||
<div className="relative my-2">
|
|
||||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t" /></div>
|
|
||||||
<div className="relative flex justify-center text-xs"><span className="bg-white px-2 text-gray-500">계정이 없으신가요?</span></div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full border-2 border-primary text-primary">회원가입</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 2: 아이콘 + 간결한 레이아웃
|
|
||||||
function FindPwDesign2() {
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
|
||||||
<h1 className="text-2xl font-bold">비밀번호 찾기</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
등록된 정보로 비밀번호를 재설정할 수 있습니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">아이디</label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<Input placeholder="아이디를 입력하세요" className="pl-10 h-11" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이메일</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<Input type="email" placeholder="이메일을 입력하세요" className="pl-10 h-11" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button className="w-full h-11">인증번호 발송</Button>
|
|
||||||
<div className="relative my-2">
|
|
||||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t" /></div>
|
|
||||||
<div className="relative flex justify-center text-xs"><span className="bg-white px-2 text-gray-500">또는</span></div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full h-11 border-2 border-primary text-primary">로그인으로 돌아가기</Button>
|
|
||||||
<div className="text-center">
|
|
||||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">아이디를 잊으셨나요?</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 3: 뒤로가기 버튼 + 좌측 정렬
|
|
||||||
function FindPwDesign3() {
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<a href="#" className="flex items-center gap-1 text-sm text-gray-500 hover:text-primary mb-2">
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
로그인으로 돌아가기
|
|
||||||
</a>
|
|
||||||
<div className="flex flex-col gap-1 mb-2">
|
|
||||||
<h1 className="text-2xl font-bold">비밀번호 찾기</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
아이디와 이메일을 입력해주세요
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">아이디</label>
|
|
||||||
<Input placeholder="아이디를 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이메일</label>
|
|
||||||
<Input type="email" placeholder="이메일을 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<Button className="w-full h-11 mt-2">인증번호 발송</Button>
|
|
||||||
<div className="flex items-center justify-center gap-4 text-sm text-gray-500 mt-2">
|
|
||||||
<a href="#" className="hover:text-primary">아이디 찾기</a>
|
|
||||||
<span>|</span>
|
|
||||||
<a href="#" className="hover:text-primary">회원가입</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 4: 로그인과 통일된 스타일 (추천)
|
|
||||||
function FindPwDesign4() {
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
|
||||||
<h1 className="text-2xl font-bold">비밀번호 찾기</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
등록된 정보를 입력해주세요
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">아이디</label>
|
|
||||||
<Input placeholder="아이디를 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-sm font-medium">이메일</label>
|
|
||||||
<a href="#" className="text-xs text-primary hover:underline">아이디 찾기</a>
|
|
||||||
</div>
|
|
||||||
<Input type="email" placeholder="이메일을 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<Button className="w-full h-11">인증번호 발송</Button>
|
|
||||||
<div className="relative my-2">
|
|
||||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t" /></div>
|
|
||||||
<div className="relative flex justify-center text-xs"><span className="bg-white px-2 text-gray-500">또는</span></div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full h-11 border-2 border-primary text-primary">로그인</Button>
|
|
||||||
<div className="text-center">
|
|
||||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">계정이 없으신가요? 회원가입</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FindPwDemo() {
|
|
||||||
const designs = [
|
|
||||||
{ id: "current", name: "현재", description: "현재 적용된 디자인", features: ["기존 레이아웃"], component: FindPwDesign1 },
|
|
||||||
{ id: "icon", name: "시안 2", description: "아이콘 + 간결한 레이아웃", features: ["입력 필드 아이콘", "간결한 하단 링크"], component: FindPwDesign2 },
|
|
||||||
{ id: "back", name: "시안 3", description: "뒤로가기 버튼 + 좌측 정렬 제목", features: ["뒤로가기 버튼", "좌측 정렬"], component: FindPwDesign3 },
|
|
||||||
{ id: "unified", name: "시안 4", description: "로그인과 통일된 스타일 (추천)", features: ["로그인 스타일 통일", "아이디 찾기 위치"], component: FindPwDesign4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-100 p-8">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">비밀번호 찾기 페이지 디자인 시안</h1>
|
|
||||||
<p className="text-gray-600 mt-2">각 탭을 클릭하여 다른 디자인 시안을 비교해보세요</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="current" className="space-y-6">
|
|
||||||
<TabsList className="grid grid-cols-4 w-full h-auto p-1">
|
|
||||||
{designs.map((design) => (
|
|
||||||
<TabsTrigger key={design.id} value={design.id} className="py-3 text-xs sm:text-sm data-[state=active]:bg-primary data-[state=active]:text-white">
|
|
||||||
{design.name}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{designs.map((design) => (
|
|
||||||
<TabsContent key={design.id} value={design.id} className="space-y-4">
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">{design.name}: {design.description}</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{design.features.map((feature, idx) => (
|
|
||||||
<Badge key={idx} variant="secondary">{feature}</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<design.component />
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,549 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { LogIn, Eye, EyeOff, User, Lock } from "lucide-react";
|
|
||||||
|
|
||||||
// 시안 1: 현재 디자인
|
|
||||||
function LoginDesign1() {
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image
|
|
||||||
src="/logo-graphic.svg"
|
|
||||||
alt="로고"
|
|
||||||
fill
|
|
||||||
className="object-contain p-16"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
|
||||||
<h1 className="text-2xl font-bold">로그인</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
한우 유전능력 컨설팅 서비스에 오신 것을 환영합니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">아이디</label>
|
|
||||||
<Input placeholder="아이디를 입력하세요" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">비밀번호</label>
|
|
||||||
<Input type="password" placeholder="비밀번호를 입력하세요" />
|
|
||||||
</div>
|
|
||||||
<Button className="w-full">로그인</Button>
|
|
||||||
<div className="relative my-2">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-white px-2 text-muted-foreground">계정이 없으신가요?</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full border-2 border-primary text-primary">
|
|
||||||
회원가입
|
|
||||||
</Button>
|
|
||||||
<div className="text-center text-sm">
|
|
||||||
<a href="#" className="hover:underline">아이디 찾기</a>
|
|
||||||
{" | "}
|
|
||||||
<a href="#" className="hover:underline">비밀번호 찾기</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 2: 현재 + 비밀번호 토글 + 아이콘
|
|
||||||
function LoginDesign2() {
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image
|
|
||||||
src="/logo-graphic.svg"
|
|
||||||
alt="로고"
|
|
||||||
fill
|
|
||||||
className="object-contain p-16"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
|
||||||
<h1 className="text-2xl font-bold">로그인</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
한우 유전능력 컨설팅 서비스에 오신 것을 환영합니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">아이디</label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<Input placeholder="아이디를 입력하세요" className="pl-10" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">비밀번호</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder="비밀번호를 입력하세요"
|
|
||||||
className="pl-10 pr-10"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button className="w-full">
|
|
||||||
<LogIn className="w-4 h-4 mr-2" />
|
|
||||||
로그인
|
|
||||||
</Button>
|
|
||||||
<div className="relative my-2">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-white px-2 text-muted-foreground">계정이 없으신가요?</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full border-2 border-primary text-primary">
|
|
||||||
회원가입
|
|
||||||
</Button>
|
|
||||||
<div className="text-center text-sm">
|
|
||||||
<a href="#" className="hover:underline">아이디 찾기</a>
|
|
||||||
{" | "}
|
|
||||||
<a href="#" className="hover:underline">비밀번호 찾기</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 3: 현재 + 더 큰 입력 필드 + 부드러운 그림자
|
|
||||||
function LoginDesign3() {
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-gradient-to-br from-slate-50 to-slate-100 relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image
|
|
||||||
src="/logo-graphic.svg"
|
|
||||||
alt="로고"
|
|
||||||
fill
|
|
||||||
className="object-contain p-16"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-12">
|
|
||||||
<div className="w-full max-w-[360px]">
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="flex flex-col items-center gap-2 text-center mb-4">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">로그인</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
한우 유전능력 컨설팅 서비스에 오신 것을 환영합니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700">아이디</label>
|
|
||||||
<Input
|
|
||||||
placeholder="아이디를 입력하세요"
|
|
||||||
className="h-12 text-base shadow-sm border-gray-200 focus:border-primary focus:ring-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700">비밀번호</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder="비밀번호를 입력하세요"
|
|
||||||
className="h-12 text-base pr-10 shadow-sm border-gray-200 focus:border-primary focus:ring-primary"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button className="w-full h-12 text-base shadow-md hover:shadow-lg transition-shadow">
|
|
||||||
로그인
|
|
||||||
</Button>
|
|
||||||
<div className="relative my-2">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t border-gray-200" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs">
|
|
||||||
<span className="bg-white px-3 text-gray-500">계정이 없으신가요?</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full h-12 text-base border-2 border-primary text-primary hover:bg-primary hover:text-white transition-colors">
|
|
||||||
회원가입
|
|
||||||
</Button>
|
|
||||||
<div className="text-center text-sm text-gray-500">
|
|
||||||
<a href="#" className="hover:text-primary transition-colors">아이디 찾기</a>
|
|
||||||
<span className="mx-2">|</span>
|
|
||||||
<a href="#" className="hover:text-primary transition-colors">비밀번호 찾기</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 4: 현재 + 아이디 저장 + 간결한 링크
|
|
||||||
function LoginDesign4() {
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image
|
|
||||||
src="/logo-graphic.svg"
|
|
||||||
alt="로고"
|
|
||||||
fill
|
|
||||||
className="object-contain p-16"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
|
||||||
<h1 className="text-2xl font-bold">로그인</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
한우 유전능력 컨설팅 서비스에 오신 것을 환영합니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">아이디</label>
|
|
||||||
<Input placeholder="아이디를 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-sm font-medium">비밀번호</label>
|
|
||||||
<a href="#" className="text-xs text-primary hover:underline">비밀번호 찾기</a>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder="비밀번호를 입력하세요"
|
|
||||||
className="h-11 pr-10"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" className="rounded border-gray-300 text-primary focus:ring-primary" />
|
|
||||||
<span className="text-sm text-gray-600">아이디 저장</span>
|
|
||||||
</label>
|
|
||||||
<Button className="w-full h-11">로그인</Button>
|
|
||||||
<div className="relative my-2">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-white px-2 text-muted-foreground">또는</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full h-11 border-2 border-primary text-primary">
|
|
||||||
회원가입
|
|
||||||
</Button>
|
|
||||||
<div className="text-center">
|
|
||||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">아이디를 잊으셨나요?</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 5: 현재 + 컬러 강조 배경
|
|
||||||
function LoginDesign5() {
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-primary/5 relative hidden lg:flex items-center justify-center">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 via-transparent to-primary/5" />
|
|
||||||
<Image
|
|
||||||
src="/logo-graphic.svg"
|
|
||||||
alt="로고"
|
|
||||||
fill
|
|
||||||
className="object-contain p-16"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
|
||||||
<h1 className="text-2xl font-bold text-primary">로그인</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
한우 유전능력 컨설팅 서비스에 오신 것을 환영합니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">아이디</label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary/60" />
|
|
||||||
<Input
|
|
||||||
placeholder="아이디를 입력하세요"
|
|
||||||
className="pl-10 h-11 border-primary/20 focus:border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">비밀번호</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary/60" />
|
|
||||||
<Input
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder="비밀번호를 입력하세요"
|
|
||||||
className="pl-10 pr-10 h-11 border-primary/20 focus:border-primary"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-primary"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button className="w-full h-11 bg-primary hover:bg-primary/90">
|
|
||||||
<LogIn className="w-4 h-4 mr-2" />
|
|
||||||
로그인
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" className="rounded border-primary/30 text-primary focus:ring-primary" />
|
|
||||||
<span className="text-gray-600">아이디 저장</span>
|
|
||||||
</label>
|
|
||||||
<a href="#" className="text-primary hover:underline">비밀번호 찾기</a>
|
|
||||||
</div>
|
|
||||||
<div className="relative my-2">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t border-primary/20" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs">
|
|
||||||
<span className="bg-white px-2 text-gray-500">계정이 없으신가요?</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full h-11 border-2 border-primary text-primary hover:bg-primary/5">
|
|
||||||
회원가입
|
|
||||||
</Button>
|
|
||||||
<div className="text-center">
|
|
||||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">아이디 찾기</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 6: 현재 + 라운드 스타일
|
|
||||||
function LoginDesign6() {
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image
|
|
||||||
src="/logo-graphic.svg"
|
|
||||||
alt="로고"
|
|
||||||
fill
|
|
||||||
className="object-contain p-16"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-slate-50">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-12">
|
|
||||||
<div className="w-full max-w-[360px] bg-white p-8 rounded-2xl shadow-lg">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
|
||||||
<h1 className="text-2xl font-bold">로그인</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
한우 유전능력 컨설팅 서비스
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">아이디</label>
|
|
||||||
<Input
|
|
||||||
placeholder="아이디를 입력하세요"
|
|
||||||
className="h-11 rounded-xl bg-slate-50 border-0 focus:bg-white focus:ring-2 focus:ring-primary/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">비밀번호</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder="비밀번호를 입력하세요"
|
|
||||||
className="h-11 pr-10 rounded-xl bg-slate-50 border-0 focus:bg-white focus:ring-2 focus:ring-primary/20"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" className="rounded-md border-gray-300" />
|
|
||||||
<span className="text-gray-600">아이디 저장</span>
|
|
||||||
</label>
|
|
||||||
<a href="#" className="text-primary hover:underline">비밀번호 찾기</a>
|
|
||||||
</div>
|
|
||||||
<Button className="w-full h-11 rounded-xl">로그인</Button>
|
|
||||||
<Button variant="outline" className="w-full h-11 rounded-xl border-2">
|
|
||||||
회원가입
|
|
||||||
</Button>
|
|
||||||
<div className="text-center">
|
|
||||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">아이디 찾기</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuthPagesDemo() {
|
|
||||||
const designs = [
|
|
||||||
{
|
|
||||||
id: "current",
|
|
||||||
name: "현재",
|
|
||||||
description: "현재 적용된 디자인",
|
|
||||||
features: ["기존 레이아웃"],
|
|
||||||
component: LoginDesign1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "icon",
|
|
||||||
name: "시안 2",
|
|
||||||
description: "아이콘 + 비밀번호 토글 추가",
|
|
||||||
features: ["입력 필드 아이콘", "비밀번호 보기"],
|
|
||||||
component: LoginDesign2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "large",
|
|
||||||
name: "시안 3",
|
|
||||||
description: "더 큰 입력 필드 + 그림자",
|
|
||||||
features: ["h-12 입력필드", "그림자 효과", "부드러운 배경"],
|
|
||||||
component: LoginDesign3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "save",
|
|
||||||
name: "시안 4",
|
|
||||||
description: "아이디 저장 + 간결한 링크",
|
|
||||||
features: ["아이디 저장", "비밀번호 찾기 위치 변경"],
|
|
||||||
component: LoginDesign4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "color",
|
|
||||||
name: "시안 5",
|
|
||||||
description: "브랜드 컬러 강조",
|
|
||||||
features: ["컬러 배경", "컬러 아이콘", "컬러 제목"],
|
|
||||||
component: LoginDesign5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "round",
|
|
||||||
name: "시안 6",
|
|
||||||
description: "라운드 카드 스타일",
|
|
||||||
features: ["라운드 입력필드", "카드 레이아웃", "부드러운 그림자"],
|
|
||||||
component: LoginDesign6
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-100 p-8">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">로그인 페이지 디자인 시안</h1>
|
|
||||||
<p className="text-gray-600 mt-2">
|
|
||||||
현재 디자인 기반 개선안 - 각 탭을 클릭하여 비교해보세요
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="current" className="space-y-6">
|
|
||||||
<TabsList className="grid grid-cols-6 w-full h-auto p-1">
|
|
||||||
{designs.map((design) => (
|
|
||||||
<TabsTrigger
|
|
||||||
key={design.id}
|
|
||||||
value={design.id}
|
|
||||||
className="py-3 text-xs sm:text-sm data-[state=active]:bg-primary data-[state=active]:text-white"
|
|
||||||
>
|
|
||||||
{design.name}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{designs.map((design) => (
|
|
||||||
<TabsContent key={design.id} value={design.id} className="space-y-4">
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">{design.name}: {design.description}</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{design.features.map((feature, idx) => (
|
|
||||||
<Badge key={idx} variant="secondary">{feature}</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<design.component />
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,455 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
|
|
||||||
|
|
||||||
// 시안 1: 현재 디자인 (3단계 스텝)
|
|
||||||
function SignupDesign1() {
|
|
||||||
const [step, setStep] = useState(1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
|
||||||
<h1 className="text-2xl font-bold">회원가입</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{step === 1 && "기본 정보"}
|
|
||||||
{step === 2 && "이메일 인증"}
|
|
||||||
{step === 3 && "추가 정보"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/* 스텝 인디케이터 */}
|
|
||||||
<div className="flex items-center justify-center gap-2 py-2">
|
|
||||||
{[1, 2, 3].map((s) => (
|
|
||||||
<div key={s} className="flex items-center">
|
|
||||||
<div className={cn(
|
|
||||||
"w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium",
|
|
||||||
step === s ? "bg-primary text-white" : step > s ? "bg-primary/20 text-primary" : "bg-gray-100 text-gray-400"
|
|
||||||
)}>
|
|
||||||
{step > s ? <CheckCircle2 className="w-4 h-4" /> : s}
|
|
||||||
</div>
|
|
||||||
{s < 3 && <div className={cn("w-8 h-0.5 mx-1", step > s ? "bg-primary/20" : "bg-gray-200")} />}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{step === 1 && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">회원 유형 *</label>
|
|
||||||
<Select><SelectTrigger><SelectValue placeholder="회원 유형을 선택하세요" /></SelectTrigger>
|
|
||||||
<SelectContent><SelectItem value="FARM">농가</SelectItem><SelectItem value="CNSLT">컨설턴트</SelectItem></SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">아이디 *</label>
|
|
||||||
<Input placeholder="아이디를 입력하세요 (4자 이상)" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이름 *</label>
|
|
||||||
<Input placeholder="이름을 입력하세요" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{step === 2 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이메일 *</label>
|
|
||||||
<Input type="email" placeholder="이메일을 입력하세요" />
|
|
||||||
<Button variant="outline" className="w-full">인증번호 발송</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{step === 3 && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">휴대폰 번호 *</label>
|
|
||||||
<Input placeholder="010-0000-0000" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">비밀번호 *</label>
|
|
||||||
<Input type="password" placeholder="비밀번호 (8자 이상)" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
{step > 1 && (
|
|
||||||
<Button variant="outline" onClick={() => setStep(s => s - 1)} className="flex-1">
|
|
||||||
<ChevronLeft className="w-4 h-4 mr-1" />이전
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{step < 3 ? (
|
|
||||||
<Button onClick={() => setStep(s => s + 1)} className="flex-1">다음<ChevronRight className="w-4 h-4 ml-1" /></Button>
|
|
||||||
) : (
|
|
||||||
<Button className="flex-1">회원가입</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full">로그인으로 돌아가기</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 2: 스텝에 라벨 추가
|
|
||||||
function SignupDesign2() {
|
|
||||||
const [step, setStep] = useState(1);
|
|
||||||
const steps = [{ num: 1, label: "기본정보" }, { num: 2, label: "이메일인증" }, { num: 3, label: "추가정보" }];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[360px]">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
|
||||||
<h1 className="text-2xl font-bold">회원가입</h1>
|
|
||||||
</div>
|
|
||||||
{/* 스텝 인디케이터 with 라벨 */}
|
|
||||||
<div className="flex items-center justify-between py-4">
|
|
||||||
{steps.map((s, idx) => (
|
|
||||||
<div key={s.num} className="flex flex-col items-center flex-1">
|
|
||||||
<div className="flex items-center w-full">
|
|
||||||
{idx > 0 && <div className={cn("flex-1 h-0.5", step > idx ? "bg-primary" : "bg-gray-200")} />}
|
|
||||||
<div className={cn(
|
|
||||||
"w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium shrink-0",
|
|
||||||
step === s.num ? "bg-primary text-white" : step > s.num ? "bg-primary text-white" : "bg-gray-100 text-gray-400"
|
|
||||||
)}>
|
|
||||||
{step > s.num ? <CheckCircle2 className="w-5 h-5" /> : s.num}
|
|
||||||
</div>
|
|
||||||
{idx < 2 && <div className={cn("flex-1 h-0.5", step > s.num ? "bg-primary" : "bg-gray-200")} />}
|
|
||||||
</div>
|
|
||||||
<span className={cn("text-xs mt-2", step >= s.num ? "text-primary font-medium" : "text-gray-400")}>{s.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{step === 1 && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">회원 유형 *</label>
|
|
||||||
<Select><SelectTrigger className="h-11"><SelectValue placeholder="회원 유형을 선택하세요" /></SelectTrigger>
|
|
||||||
<SelectContent><SelectItem value="FARM">농가</SelectItem><SelectItem value="CNSLT">컨설턴트</SelectItem></SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">아이디 *</label>
|
|
||||||
<Input placeholder="아이디를 입력하세요 (4자 이상)" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이름 *</label>
|
|
||||||
<Input placeholder="이름을 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{step === 2 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이메일 *</label>
|
|
||||||
<Input type="email" placeholder="이메일을 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full h-11">인증번호 발송</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{step === 3 && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">휴대폰 번호 *</label>
|
|
||||||
<Input placeholder="010-0000-0000" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">비밀번호 *</label>
|
|
||||||
<Input type="password" placeholder="비밀번호 (8자 이상)" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">비밀번호 확인 *</label>
|
|
||||||
<Input type="password" placeholder="비밀번호를 다시 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
{step > 1 && (
|
|
||||||
<Button variant="outline" onClick={() => setStep(s => s - 1)} className="flex-1 h-11">
|
|
||||||
<ChevronLeft className="w-4 h-4 mr-1" />이전
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{step < 3 ? (
|
|
||||||
<Button onClick={() => setStep(s => s + 1)} className="flex-1 h-11">다음<ChevronRight className="w-4 h-4 ml-1" /></Button>
|
|
||||||
) : (
|
|
||||||
<Button className="flex-1 h-11">회원가입</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full h-11">로그인으로 돌아가기</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 3: 프로그레스 바 스타일
|
|
||||||
function SignupDesign3() {
|
|
||||||
const [step, setStep] = useState(1);
|
|
||||||
const progress = ((step - 1) / 2) * 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
|
||||||
<h1 className="text-2xl font-bold">회원가입</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{step === 1 && "기본 정보를 입력해주세요"}
|
|
||||||
{step === 2 && "이메일 인증을 진행해주세요"}
|
|
||||||
{step === 3 && "마지막 단계입니다"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/* 프로그레스 바 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-xs text-gray-500">
|
|
||||||
<span>단계 {step}/3</span>
|
|
||||||
<span>{Math.round(progress)}% 완료</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-primary transition-all duration-300" style={{ width: `${progress}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{step === 1 && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">회원 유형 *</label>
|
|
||||||
<Select><SelectTrigger className="h-11"><SelectValue placeholder="회원 유형을 선택하세요" /></SelectTrigger>
|
|
||||||
<SelectContent><SelectItem value="FARM">농가</SelectItem><SelectItem value="CNSLT">컨설턴트</SelectItem></SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">아이디 *</label>
|
|
||||||
<Input placeholder="아이디를 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이름 *</label>
|
|
||||||
<Input placeholder="이름을 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{step === 2 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이메일 *</label>
|
|
||||||
<Input type="email" placeholder="이메일을 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full h-11">인증번호 발송</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{step === 3 && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">휴대폰 번호 *</label>
|
|
||||||
<Input placeholder="010-0000-0000" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">비밀번호 *</label>
|
|
||||||
<Input type="password" placeholder="비밀번호 (8자 이상)" className="h-11" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
{step > 1 && (
|
|
||||||
<Button variant="outline" onClick={() => setStep(s => s - 1)} className="flex-1 h-11">이전</Button>
|
|
||||||
)}
|
|
||||||
{step < 3 ? (
|
|
||||||
<Button onClick={() => setStep(s => s + 1)} className="flex-1 h-11">다음</Button>
|
|
||||||
) : (
|
|
||||||
<Button className="flex-1 h-11">회원가입 완료</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">이미 계정이 있으신가요? 로그인</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 4: 현재 + 개선 (추천)
|
|
||||||
function SignupDesign4() {
|
|
||||||
const [step, setStep] = useState(1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
|
||||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
|
||||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
|
||||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
|
||||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
|
||||||
<h1 className="text-2xl font-bold">회원가입</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{step === 1 && "기본 정보"}
|
|
||||||
{step === 2 && "이메일 인증"}
|
|
||||||
{step === 3 && "추가 정보"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/* 스텝 인디케이터 */}
|
|
||||||
<div className="flex items-center justify-center gap-2 py-2">
|
|
||||||
{[1, 2, 3].map((s) => (
|
|
||||||
<div key={s} className="flex items-center">
|
|
||||||
<div className={cn(
|
|
||||||
"w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors",
|
|
||||||
step === s ? "bg-primary text-white" : step > s ? "bg-primary/20 text-primary" : "bg-gray-100 text-gray-400"
|
|
||||||
)}>
|
|
||||||
{step > s ? <CheckCircle2 className="w-4 h-4" /> : s}
|
|
||||||
</div>
|
|
||||||
{s < 3 && <div className={cn("w-8 h-0.5 mx-1 transition-colors", step > s ? "bg-primary/20" : "bg-gray-200")} />}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{step === 1 && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">회원 유형 *</label>
|
|
||||||
<Select><SelectTrigger className="h-11"><SelectValue placeholder="회원 유형을 선택하세요" /></SelectTrigger>
|
|
||||||
<SelectContent><SelectItem value="FARM">농가</SelectItem><SelectItem value="CNSLT">컨설턴트</SelectItem><SelectItem value="ORGAN">기관</SelectItem></SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">아이디 *</label>
|
|
||||||
<Input placeholder="아이디를 입력하세요 (4자 이상)" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이름 *</label>
|
|
||||||
<Input placeholder="이름을 입력하세요 (2자 이상)" className="h-11" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{step === 2 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">이메일 *</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input type="text" placeholder="이메일 아이디" className="h-11 flex-1" />
|
|
||||||
<span className="flex items-center text-gray-400">@</span>
|
|
||||||
<Select><SelectTrigger className="h-11 flex-1"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
||||||
<SelectContent><SelectItem value="gmail.com">gmail.com</SelectItem><SelectItem value="naver.com">naver.com</SelectItem></SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full h-11">인증번호 발송</Button>
|
|
||||||
<p className="text-xs text-center text-green-600">✓ 이메일 인증이 완료되면 다음 단계로 진행됩니다</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{step === 3 && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">휴대폰 번호 *</label>
|
|
||||||
<Input placeholder="010-0000-0000" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">비밀번호 *</label>
|
|
||||||
<Input type="password" placeholder="비밀번호를 입력하세요 (8자 이상)" className="h-11" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">비밀번호 확인 *</label>
|
|
||||||
<Input type="password" placeholder="비밀번호를 다시 입력하세요" className="h-11" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
{step > 1 && (
|
|
||||||
<Button variant="outline" onClick={() => setStep(s => s - 1)} className="flex-1 h-11">
|
|
||||||
<ChevronLeft className="w-4 h-4 mr-1" />이전
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{step < 3 ? (
|
|
||||||
<Button onClick={() => setStep(s => s + 1)} className="flex-1 h-11">다음<ChevronRight className="w-4 h-4 ml-1" /></Button>
|
|
||||||
) : (
|
|
||||||
<Button className="flex-1 h-11">회원가입</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="relative my-2">
|
|
||||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t" /></div>
|
|
||||||
<div className="relative flex justify-center text-xs"><span className="bg-white px-2 text-gray-500">또는</span></div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" className="w-full h-11 border-2 border-primary text-primary">로그인</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SignupDemo() {
|
|
||||||
const designs = [
|
|
||||||
{ id: "current", name: "현재", description: "현재 적용된 3단계 스텝", features: ["숫자 인디케이터"], component: SignupDesign1 },
|
|
||||||
{ id: "label", name: "시안 2", description: "스텝에 라벨 추가", features: ["단계별 라벨", "연결선"], component: SignupDesign2 },
|
|
||||||
{ id: "progress", name: "시안 3", description: "프로그레스 바 스타일", features: ["진행률 바", "퍼센트 표시"], component: SignupDesign3 },
|
|
||||||
{ id: "improved", name: "시안 4", description: "현재 + 개선 (추천)", features: ["h-11 입력필드", "로그인 통일 스타일"], component: SignupDesign4 },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-100 p-8">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">회원가입 페이지 디자인 시안</h1>
|
|
||||||
<p className="text-gray-600 mt-2">각 탭을 클릭하여 다른 디자인 시안을 비교해보세요</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="current" className="space-y-6">
|
|
||||||
<TabsList className="grid grid-cols-4 w-full h-auto p-1">
|
|
||||||
{designs.map((design) => (
|
|
||||||
<TabsTrigger key={design.id} value={design.id} className="py-3 text-xs sm:text-sm data-[state=active]:bg-primary data-[state=active]:text-white">
|
|
||||||
{design.name}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{designs.map((design) => (
|
|
||||||
<TabsContent key={design.id} value={design.id} className="space-y-4">
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">{design.name}: {design.description}</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{design.features.map((feature, idx) => (
|
|
||||||
<Badge key={idx} variant="secondary">{feature}</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
|
||||||
<design.component />
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,485 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
import {
|
|
||||||
Area,
|
|
||||||
ComposedChart,
|
|
||||||
ResponsiveContainer,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
ReferenceLine,
|
|
||||||
Customized,
|
|
||||||
} from 'recharts'
|
|
||||||
|
|
||||||
// 샘플 데이터
|
|
||||||
const SAMPLE_DATA = {
|
|
||||||
cow: { name: '7805', score: 0.85 },
|
|
||||||
farm: { name: '농가', score: 0.53 },
|
|
||||||
region: { name: '보은군', score: 0.21 },
|
|
||||||
}
|
|
||||||
|
|
||||||
// 정규분포 히스토그램 데이터
|
|
||||||
const histogramData = [
|
|
||||||
{ midPoint: -2.5, percent: 2.3 },
|
|
||||||
{ midPoint: -2.0, percent: 4.4 },
|
|
||||||
{ midPoint: -1.5, percent: 9.2 },
|
|
||||||
{ midPoint: -1.0, percent: 15.0 },
|
|
||||||
{ midPoint: -0.5, percent: 19.1 },
|
|
||||||
{ midPoint: 0.0, percent: 19.1 },
|
|
||||||
{ midPoint: 0.5, percent: 15.0 },
|
|
||||||
{ midPoint: 1.0, percent: 9.2 },
|
|
||||||
{ midPoint: 1.5, percent: 4.4 },
|
|
||||||
{ midPoint: 2.0, percent: 2.3 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function ChartOptionsDemo() {
|
|
||||||
const [selectedOption, setSelectedOption] = useState<string>('A')
|
|
||||||
|
|
||||||
const cowScore = SAMPLE_DATA.cow.score
|
|
||||||
const farmScore = SAMPLE_DATA.farm.score
|
|
||||||
const regionScore = SAMPLE_DATA.region.score
|
|
||||||
|
|
||||||
const farmDiff = cowScore - farmScore
|
|
||||||
const regionDiff = cowScore - regionScore
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-slate-100 p-4 sm:p-8">
|
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">차트 대비 표시 옵션 데모</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
개체: +{cowScore.toFixed(2)} | 농가: +{farmScore.toFixed(2)} | 보은군: +{regionScore.toFixed(2)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 옵션 선택 탭 */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{['A', 'B', 'C', 'D', 'E'].map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt}
|
|
||||||
onClick={() => setSelectedOption(opt)}
|
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
|
||||||
selectedOption === opt
|
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'bg-white text-foreground hover:bg-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
옵션 {opt}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 옵션 A: 차트 내에 대비값 항상 표시 */}
|
|
||||||
{selectedOption === 'A' && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<h2 className="text-lg font-bold mb-2">옵션 A: 차트 내에 대비값 항상 표시</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">개체 선 옆에 농가/보은군 대비값을 직접 표시</p>
|
|
||||||
|
|
||||||
<div className="h-[400px] bg-slate-50 rounded-xl p-4">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<ComposedChart data={histogramData} margin={{ top: 80, right: 30, left: 10, bottom: 30 }}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="areaGradientA" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.4} />
|
|
||||||
<stop offset="100%" stopColor="#93c5fd" stopOpacity={0.1} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<XAxis dataKey="midPoint" type="number" domain={[-2.5, 2.5]} tick={{ fontSize: 11 }} />
|
|
||||||
<YAxis tick={{ fontSize: 10 }} width={30} />
|
|
||||||
<Area type="natural" dataKey="percent" stroke="#3b82f6" fill="url(#areaGradientA)" />
|
|
||||||
<ReferenceLine x={regionScore} stroke="#2563eb" strokeWidth={2} strokeDasharray="4 2" />
|
|
||||||
<ReferenceLine x={farmScore} stroke="#f59e0b" strokeWidth={2} strokeDasharray="4 2" />
|
|
||||||
<ReferenceLine x={cowScore} stroke="#1482B0" strokeWidth={3} />
|
|
||||||
|
|
||||||
<Customized
|
|
||||||
component={(props: any) => {
|
|
||||||
const { xAxisMap, yAxisMap } = props
|
|
||||||
if (!xAxisMap || !yAxisMap) return null
|
|
||||||
const xAxis = Object.values(xAxisMap)[0] as any
|
|
||||||
const yAxis = Object.values(yAxisMap)[0] as any
|
|
||||||
if (!xAxis || !yAxis) return null
|
|
||||||
|
|
||||||
const chartX = xAxis.x
|
|
||||||
const chartWidth = xAxis.width
|
|
||||||
const chartTop = yAxis.y
|
|
||||||
const [domainMin, domainMax] = xAxis.domain || [-2.5, 2.5]
|
|
||||||
const domainRange = domainMax - domainMin
|
|
||||||
|
|
||||||
const sigmaToX = (sigma: number) => chartX + ((sigma - domainMin) / domainRange) * chartWidth
|
|
||||||
const cowX = sigmaToX(cowScore)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g>
|
|
||||||
{/* 개체 라벨 + 대비값 */}
|
|
||||||
<rect x={cowX + 10} y={chartTop + 20} width={120} height={50} rx={6} fill="#1482B0" />
|
|
||||||
<text x={cowX + 70} y={chartTop + 38} textAnchor="middle" fill="white" fontSize={12} fontWeight={600}>
|
|
||||||
개체 +{cowScore.toFixed(2)}
|
|
||||||
</text>
|
|
||||||
<text x={cowX + 70} y={chartTop + 55} textAnchor="middle" fill="white" fontSize={10}>
|
|
||||||
농가+{farmDiff.toFixed(2)} | 보은군+{regionDiff.toFixed(2)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 옵션 B: 선 사이 영역 색으로 채우기 */}
|
|
||||||
{selectedOption === 'B' && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<h2 className="text-lg font-bold mb-2">옵션 B: 선 사이 영역 색으로 채우기</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">개체~농가, 개체~보은군 사이를 색으로 강조</p>
|
|
||||||
|
|
||||||
<div className="h-[400px] bg-slate-50 rounded-xl p-4">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<ComposedChart data={histogramData} margin={{ top: 80, right: 30, left: 10, bottom: 30 }}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="areaGradientB" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.4} />
|
|
||||||
<stop offset="100%" stopColor="#93c5fd" stopOpacity={0.1} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<XAxis dataKey="midPoint" type="number" domain={[-2.5, 2.5]} tick={{ fontSize: 11 }} />
|
|
||||||
<YAxis tick={{ fontSize: 10 }} width={30} />
|
|
||||||
<Area type="natural" dataKey="percent" stroke="#3b82f6" fill="url(#areaGradientB)" />
|
|
||||||
<ReferenceLine x={regionScore} stroke="#2563eb" strokeWidth={2} strokeDasharray="4 2" />
|
|
||||||
<ReferenceLine x={farmScore} stroke="#f59e0b" strokeWidth={2} strokeDasharray="4 2" />
|
|
||||||
<ReferenceLine x={cowScore} stroke="#1482B0" strokeWidth={3} />
|
|
||||||
|
|
||||||
<Customized
|
|
||||||
component={(props: any) => {
|
|
||||||
const { xAxisMap, yAxisMap } = props
|
|
||||||
if (!xAxisMap || !yAxisMap) return null
|
|
||||||
const xAxis = Object.values(xAxisMap)[0] as any
|
|
||||||
const yAxis = Object.values(yAxisMap)[0] as any
|
|
||||||
if (!xAxis || !yAxis) return null
|
|
||||||
|
|
||||||
const chartX = xAxis.x
|
|
||||||
const chartWidth = xAxis.width
|
|
||||||
const chartTop = yAxis.y
|
|
||||||
const chartHeight = yAxis.height
|
|
||||||
const [domainMin, domainMax] = xAxis.domain || [-2.5, 2.5]
|
|
||||||
const domainRange = domainMax - domainMin
|
|
||||||
|
|
||||||
const sigmaToX = (sigma: number) => chartX + ((sigma - domainMin) / domainRange) * chartWidth
|
|
||||||
const cowX = sigmaToX(cowScore)
|
|
||||||
const farmX = sigmaToX(farmScore)
|
|
||||||
const regionX = sigmaToX(regionScore)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g>
|
|
||||||
{/* 개체~농가 영역 (주황색) */}
|
|
||||||
<rect
|
|
||||||
x={farmX}
|
|
||||||
y={chartTop}
|
|
||||||
width={cowX - farmX}
|
|
||||||
height={chartHeight}
|
|
||||||
fill="rgba(245, 158, 11, 0.25)"
|
|
||||||
/>
|
|
||||||
{/* 농가~보은군 영역 (파란색) */}
|
|
||||||
<rect
|
|
||||||
x={regionX}
|
|
||||||
y={chartTop}
|
|
||||||
width={farmX - regionX}
|
|
||||||
height={chartHeight}
|
|
||||||
fill="rgba(37, 99, 235, 0.15)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 대비값 라벨 */}
|
|
||||||
<rect x={(cowX + farmX) / 2 - 35} y={chartTop + 30} width={70} height={24} rx={4} fill="#f59e0b" />
|
|
||||||
<text x={(cowX + farmX) / 2} y={chartTop + 46} textAnchor="middle" fill="white" fontSize={11} fontWeight={600}>
|
|
||||||
+{farmDiff.toFixed(2)}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<rect x={(farmX + regionX) / 2 - 35} y={chartTop + 60} width={70} height={24} rx={4} fill="#2563eb" />
|
|
||||||
<text x={(farmX + regionX) / 2} y={chartTop + 76} textAnchor="middle" fill="white" fontSize={11} fontWeight={600}>
|
|
||||||
+{(farmScore - regionScore).toFixed(2)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 옵션 C: 개체 배지에 대비값 추가 */}
|
|
||||||
{selectedOption === 'C' && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<h2 className="text-lg font-bold mb-2">옵션 C: 개체 배지에 대비값 추가</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">개체 배지를 확장해서 대비값 포함</p>
|
|
||||||
|
|
||||||
<div className="h-[400px] bg-slate-50 rounded-xl p-4">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<ComposedChart data={histogramData} margin={{ top: 100, right: 30, left: 10, bottom: 30 }}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="areaGradientC" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.4} />
|
|
||||||
<stop offset="100%" stopColor="#93c5fd" stopOpacity={0.1} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<XAxis dataKey="midPoint" type="number" domain={[-2.5, 2.5]} tick={{ fontSize: 11 }} />
|
|
||||||
<YAxis tick={{ fontSize: 10 }} width={30} />
|
|
||||||
<Area type="natural" dataKey="percent" stroke="#3b82f6" fill="url(#areaGradientC)" />
|
|
||||||
<ReferenceLine x={regionScore} stroke="#2563eb" strokeWidth={2} strokeDasharray="4 2" />
|
|
||||||
<ReferenceLine x={farmScore} stroke="#f59e0b" strokeWidth={2} strokeDasharray="4 2" />
|
|
||||||
<ReferenceLine x={cowScore} stroke="#1482B0" strokeWidth={3} />
|
|
||||||
|
|
||||||
<Customized
|
|
||||||
component={(props: any) => {
|
|
||||||
const { xAxisMap, yAxisMap } = props
|
|
||||||
if (!xAxisMap || !yAxisMap) return null
|
|
||||||
const xAxis = Object.values(xAxisMap)[0] as any
|
|
||||||
const yAxis = Object.values(yAxisMap)[0] as any
|
|
||||||
if (!xAxis || !yAxis) return null
|
|
||||||
|
|
||||||
const chartX = xAxis.x
|
|
||||||
const chartWidth = xAxis.width
|
|
||||||
const chartTop = yAxis.y
|
|
||||||
const [domainMin, domainMax] = xAxis.domain || [-2.5, 2.5]
|
|
||||||
const domainRange = domainMax - domainMin
|
|
||||||
|
|
||||||
const sigmaToX = (sigma: number) => chartX + ((sigma - domainMin) / domainRange) * chartWidth
|
|
||||||
const cowX = sigmaToX(cowScore)
|
|
||||||
const farmX = sigmaToX(farmScore)
|
|
||||||
const regionX = sigmaToX(regionScore)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g>
|
|
||||||
{/* 보은군 배지 */}
|
|
||||||
<rect x={regionX - 50} y={chartTop - 85} width={100} height={26} rx={6} fill="#dbeafe" stroke="#93c5fd" strokeWidth={2} />
|
|
||||||
<text x={regionX} y={chartTop - 68} textAnchor="middle" fill="#2563eb" fontSize={12} fontWeight={600}>
|
|
||||||
보은군 +{regionScore.toFixed(2)}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* 농가 배지 */}
|
|
||||||
<rect x={farmX - 50} y={chartTop - 55} width={100} height={26} rx={6} fill="#fef3c7" stroke="#fcd34d" strokeWidth={2} />
|
|
||||||
<text x={farmX} y={chartTop - 38} textAnchor="middle" fill="#d97706" fontSize={12} fontWeight={600}>
|
|
||||||
농가 +{farmScore.toFixed(2)}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* 개체 배지 (확장) */}
|
|
||||||
<rect x={cowX - 80} y={chartTop - 25} width={160} height={40} rx={6} fill="#1482B0" />
|
|
||||||
<text x={cowX} y={chartTop - 8} textAnchor="middle" fill="white" fontSize={13} fontWeight={700}>
|
|
||||||
개체 +{cowScore.toFixed(2)}
|
|
||||||
</text>
|
|
||||||
<text x={cowX} y={chartTop + 10} textAnchor="middle" fill="rgba(255,255,255,0.9)" fontSize={10}>
|
|
||||||
농가 대비 +{farmDiff.toFixed(2)} | 보은군 대비 +{regionDiff.toFixed(2)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 옵션 D: 차트 모서리에 오버레이 박스 */}
|
|
||||||
{selectedOption === 'D' && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<h2 className="text-lg font-bold mb-2">옵션 D: 차트 모서리에 오버레이 박스</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">차트 우측 상단에 대비값 요약 박스</p>
|
|
||||||
|
|
||||||
<div className="h-[400px] bg-slate-50 rounded-xl p-4 relative">
|
|
||||||
{/* 오버레이 박스 */}
|
|
||||||
<div className="absolute top-6 right-6 bg-white rounded-lg shadow-lg border border-slate-200 p-3 z-10">
|
|
||||||
<div className="text-xs text-muted-foreground mb-2">개체 대비</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<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 bg-amber-500"></span>
|
|
||||||
<span className="text-sm">농가</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-bold text-green-600">+{farmDiff.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 bg-blue-500"></span>
|
|
||||||
<span className="text-sm">보은군</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-bold text-green-600">+{regionDiff.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<ComposedChart data={histogramData} margin={{ top: 80, right: 30, left: 10, bottom: 30 }}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="areaGradientD" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.4} />
|
|
||||||
<stop offset="100%" stopColor="#93c5fd" stopOpacity={0.1} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<XAxis dataKey="midPoint" type="number" domain={[-2.5, 2.5]} tick={{ fontSize: 11 }} />
|
|
||||||
<YAxis tick={{ fontSize: 10 }} width={30} />
|
|
||||||
<Area type="natural" dataKey="percent" stroke="#3b82f6" fill="url(#areaGradientD)" />
|
|
||||||
<ReferenceLine x={regionScore} stroke="#2563eb" strokeWidth={2} strokeDasharray="4 2" />
|
|
||||||
<ReferenceLine x={farmScore} stroke="#f59e0b" strokeWidth={2} strokeDasharray="4 2" />
|
|
||||||
<ReferenceLine x={cowScore} stroke="#1482B0" strokeWidth={3} />
|
|
||||||
|
|
||||||
<Customized
|
|
||||||
component={(props: any) => {
|
|
||||||
const { xAxisMap, yAxisMap } = props
|
|
||||||
if (!xAxisMap || !yAxisMap) return null
|
|
||||||
const xAxis = Object.values(xAxisMap)[0] as any
|
|
||||||
const yAxis = Object.values(yAxisMap)[0] as any
|
|
||||||
if (!xAxis || !yAxis) return null
|
|
||||||
|
|
||||||
const chartX = xAxis.x
|
|
||||||
const chartWidth = xAxis.width
|
|
||||||
const chartTop = yAxis.y
|
|
||||||
const [domainMin, domainMax] = xAxis.domain || [-2.5, 2.5]
|
|
||||||
const domainRange = domainMax - domainMin
|
|
||||||
|
|
||||||
const sigmaToX = (sigma: number) => chartX + ((sigma - domainMin) / domainRange) * chartWidth
|
|
||||||
const cowX = sigmaToX(cowScore)
|
|
||||||
const farmX = sigmaToX(farmScore)
|
|
||||||
const regionX = sigmaToX(regionScore)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g>
|
|
||||||
{/* 심플 배지들 */}
|
|
||||||
<rect x={regionX - 40} y={chartTop - 60} width={80} height={22} rx={4} fill="#dbeafe" stroke="#93c5fd" />
|
|
||||||
<text x={regionX} y={chartTop - 45} textAnchor="middle" fill="#2563eb" fontSize={11} fontWeight={600}>보은군</text>
|
|
||||||
|
|
||||||
<rect x={farmX - 30} y={chartTop - 35} width={60} height={22} rx={4} fill="#fef3c7" stroke="#fcd34d" />
|
|
||||||
<text x={farmX} y={chartTop - 20} textAnchor="middle" fill="#d97706" fontSize={11} fontWeight={600}>농가</text>
|
|
||||||
|
|
||||||
<rect x={cowX - 30} y={chartTop - 10} width={60} height={22} rx={4} fill="#1482B0" />
|
|
||||||
<text x={cowX} y={chartTop + 5} textAnchor="middle" fill="white" fontSize={11} fontWeight={600}>개체</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 옵션 E: 화살표로 차이 표시 */}
|
|
||||||
{selectedOption === 'E' && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<h2 className="text-lg font-bold mb-2">옵션 E: 화살표로 차이 표시</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">개체에서 농가/보은군으로 화살표 + 차이값</p>
|
|
||||||
|
|
||||||
<div className="h-[400px] bg-slate-50 rounded-xl p-4">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<ComposedChart data={histogramData} margin={{ top: 80, right: 30, left: 10, bottom: 30 }}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="areaGradientE" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.4} />
|
|
||||||
<stop offset="100%" stopColor="#93c5fd" stopOpacity={0.1} />
|
|
||||||
</linearGradient>
|
|
||||||
<marker id="arrowFarm" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth">
|
|
||||||
<path d="M0,0 L0,6 L9,3 z" fill="#f59e0b" />
|
|
||||||
</marker>
|
|
||||||
<marker id="arrowRegion" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth">
|
|
||||||
<path d="M0,0 L0,6 L9,3 z" fill="#2563eb" />
|
|
||||||
</marker>
|
|
||||||
</defs>
|
|
||||||
<XAxis dataKey="midPoint" type="number" domain={[-2.5, 2.5]} tick={{ fontSize: 11 }} />
|
|
||||||
<YAxis tick={{ fontSize: 10 }} width={30} />
|
|
||||||
<Area type="natural" dataKey="percent" stroke="#3b82f6" fill="url(#areaGradientE)" />
|
|
||||||
<ReferenceLine x={regionScore} stroke="#2563eb" strokeWidth={2} strokeDasharray="4 2" />
|
|
||||||
<ReferenceLine x={farmScore} stroke="#f59e0b" strokeWidth={2} strokeDasharray="4 2" />
|
|
||||||
<ReferenceLine x={cowScore} stroke="#1482B0" strokeWidth={3} />
|
|
||||||
|
|
||||||
<Customized
|
|
||||||
component={(props: any) => {
|
|
||||||
const { xAxisMap, yAxisMap } = props
|
|
||||||
if (!xAxisMap || !yAxisMap) return null
|
|
||||||
const xAxis = Object.values(xAxisMap)[0] as any
|
|
||||||
const yAxis = Object.values(yAxisMap)[0] as any
|
|
||||||
if (!xAxis || !yAxis) return null
|
|
||||||
|
|
||||||
const chartX = xAxis.x
|
|
||||||
const chartWidth = xAxis.width
|
|
||||||
const chartTop = yAxis.y
|
|
||||||
const chartHeight = yAxis.height
|
|
||||||
const [domainMin, domainMax] = xAxis.domain || [-2.5, 2.5]
|
|
||||||
const domainRange = domainMax - domainMin
|
|
||||||
|
|
||||||
const sigmaToX = (sigma: number) => chartX + ((sigma - domainMin) / domainRange) * chartWidth
|
|
||||||
const cowX = sigmaToX(cowScore)
|
|
||||||
const farmX = sigmaToX(farmScore)
|
|
||||||
const regionX = sigmaToX(regionScore)
|
|
||||||
|
|
||||||
const arrowY1 = chartTop + chartHeight * 0.3
|
|
||||||
const arrowY2 = chartTop + chartHeight * 0.5
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g>
|
|
||||||
{/* 개체 → 농가 화살표 */}
|
|
||||||
<line
|
|
||||||
x1={cowX} y1={arrowY1}
|
|
||||||
x2={farmX + 10} y2={arrowY1}
|
|
||||||
stroke="#f59e0b"
|
|
||||||
strokeWidth={3}
|
|
||||||
markerEnd="url(#arrowFarm)"
|
|
||||||
/>
|
|
||||||
<rect x={(cowX + farmX) / 2 - 30} y={arrowY1 - 22} width={60} height={20} rx={4} fill="#f59e0b" />
|
|
||||||
<text x={(cowX + farmX) / 2} y={arrowY1 - 8} textAnchor="middle" fill="white" fontSize={11} fontWeight={700}>
|
|
||||||
+{farmDiff.toFixed(2)}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* 개체 → 보은군 화살표 */}
|
|
||||||
<line
|
|
||||||
x1={cowX} y1={arrowY2}
|
|
||||||
x2={regionX + 10} y2={arrowY2}
|
|
||||||
stroke="#2563eb"
|
|
||||||
strokeWidth={3}
|
|
||||||
markerEnd="url(#arrowRegion)"
|
|
||||||
/>
|
|
||||||
<rect x={(cowX + regionX) / 2 - 30} y={arrowY2 - 22} width={60} height={20} rx={4} fill="#2563eb" />
|
|
||||||
<text x={(cowX + regionX) / 2} y={arrowY2 - 8} textAnchor="middle" fill="white" fontSize={11} fontWeight={700}>
|
|
||||||
+{regionDiff.toFixed(2)}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* 배지 */}
|
|
||||||
<rect x={cowX - 30} y={chartTop - 25} width={60} height={22} rx={4} fill="#1482B0" />
|
|
||||||
<text x={cowX} y={chartTop - 10} textAnchor="middle" fill="white" fontSize={11} fontWeight={600}>개체</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 옵션 설명 */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<h2 className="text-lg font-bold mb-3">옵션 비교</h2>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div><strong>A:</strong> 개체 배지 옆에 대비값 직접 표시 - 간단하지만 정보 밀집</div>
|
|
||||||
<div><strong>B:</strong> 영역 색칠로 거리감 강조 - 시각적으로 차이가 명확</div>
|
|
||||||
<div><strong>C:</strong> 개체 배지 확장 - 배지에 모든 정보 포함</div>
|
|
||||||
<div><strong>D:</strong> 오버레이 박스 - 차트 방해 없이 정보 제공</div>
|
|
||||||
<div><strong>E:</strong> 화살표 - 방향성과 차이 동시 표현</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,331 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
|
|
||||||
const emailDomains = [
|
|
||||||
'gmail.com',
|
|
||||||
'naver.com',
|
|
||||||
'daum.net',
|
|
||||||
'hanmail.net',
|
|
||||||
'nate.com',
|
|
||||||
'kakao.com',
|
|
||||||
'직접입력',
|
|
||||||
];
|
|
||||||
|
|
||||||
// 시안 1: 직접입력 시 별도 행에 입력창
|
|
||||||
function EmailDomain1() {
|
|
||||||
const [emailId, setEmailId] = useState('');
|
|
||||||
const [emailDomain, setEmailDomain] = useState('');
|
|
||||||
const [customDomain, setCustomDomain] = useState('');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="font-semibold">시안 1: 직접입력 시 별도 행</h3>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Input
|
|
||||||
placeholder="이메일"
|
|
||||||
value={emailId}
|
|
||||||
onChange={(e) => setEmailId(e.target.value)}
|
|
||||||
className="flex-1 h-11"
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground">@</span>
|
|
||||||
<Select value={emailDomain} onValueChange={setEmailDomain}>
|
|
||||||
<SelectTrigger className="flex-1 h-11">
|
|
||||||
<SelectValue placeholder="도메인 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{emailDomains.map((domain) => (
|
|
||||||
<SelectItem key={domain} value={domain}>
|
|
||||||
{domain}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{emailDomain === '직접입력' && (
|
|
||||||
<Input
|
|
||||||
placeholder="도메인을 입력하세요 (예: company.com)"
|
|
||||||
value={customDomain}
|
|
||||||
onChange={(e) => setCustomDomain(e.target.value)}
|
|
||||||
className="h-11"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
결과: {emailId}@{emailDomain === '직접입력' ? customDomain : emailDomain}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 2: 직접입력 시 드롭다운 자리에 인풋 + 옆에 드롭다운 버튼
|
|
||||||
function EmailDomain2() {
|
|
||||||
const [emailId, setEmailId] = useState('');
|
|
||||||
const [emailDomain, setEmailDomain] = useState('');
|
|
||||||
const [customDomain, setCustomDomain] = useState('');
|
|
||||||
const [isSelectOpen, setIsSelectOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="font-semibold">시안 2: 인풋 + 별도 드롭다운 버튼</h3>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Input
|
|
||||||
placeholder="이메일"
|
|
||||||
value={emailId}
|
|
||||||
onChange={(e) => setEmailId(e.target.value)}
|
|
||||||
className="flex-1 h-11"
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground">@</span>
|
|
||||||
{emailDomain === '직접입력' ? (
|
|
||||||
<div className="flex flex-1 gap-1">
|
|
||||||
<Input
|
|
||||||
placeholder="도메인 입력"
|
|
||||||
value={customDomain}
|
|
||||||
onChange={(e) => setCustomDomain(e.target.value)}
|
|
||||||
className="flex-1 h-11"
|
|
||||||
/>
|
|
||||||
<Select value={emailDomain} onValueChange={(v) => {
|
|
||||||
setEmailDomain(v);
|
|
||||||
if (v !== '직접입력') setCustomDomain('');
|
|
||||||
}}>
|
|
||||||
<SelectTrigger className="w-11 h-11 px-0 justify-center">
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{emailDomains.map((domain) => (
|
|
||||||
<SelectItem key={domain} value={domain}>
|
|
||||||
{domain}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Select value={emailDomain} onValueChange={setEmailDomain}>
|
|
||||||
<SelectTrigger className="flex-1 h-11">
|
|
||||||
<SelectValue placeholder="도메인 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{emailDomains.map((domain) => (
|
|
||||||
<SelectItem key={domain} value={domain}>
|
|
||||||
{domain}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
결과: {emailId}@{emailDomain === '직접입력' ? customDomain : emailDomain}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 3: Combobox 스타일 - 인풋이면서 드롭다운
|
|
||||||
function EmailDomain3() {
|
|
||||||
const [emailId, setEmailId] = useState('');
|
|
||||||
const [emailDomain, setEmailDomain] = useState('');
|
|
||||||
const [customDomain, setCustomDomain] = useState('');
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const displayValue = emailDomain === '직접입력' ? customDomain : emailDomain;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="font-semibold">시안 3: Combobox 스타일 (인풋 + 드롭다운 통합)</h3>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Input
|
|
||||||
placeholder="이메일"
|
|
||||||
value={emailId}
|
|
||||||
onChange={(e) => setEmailId(e.target.value)}
|
|
||||||
className="flex-1 h-11"
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground">@</span>
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Input
|
|
||||||
placeholder="도메인 선택 또는 입력"
|
|
||||||
value={displayValue}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEmailDomain('직접입력');
|
|
||||||
setCustomDomain(e.target.value);
|
|
||||||
}}
|
|
||||||
onFocus={() => setIsOpen(true)}
|
|
||||||
className="h-11 pr-10"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={emailDomain}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setEmailDomain(v);
|
|
||||||
if (v !== '직접입력') setCustomDomain('');
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={setIsOpen}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="absolute right-0 top-0 w-10 h-11 border-0 bg-transparent hover:bg-transparent focus:ring-0">
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{emailDomains.map((domain) => (
|
|
||||||
<SelectItem key={domain} value={domain}>
|
|
||||||
{domain}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
결과: {emailId}@{displayValue}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 4: 드롭다운 영역과 입력 영역 분리
|
|
||||||
function EmailDomain4() {
|
|
||||||
const [emailId, setEmailId] = useState('');
|
|
||||||
const [emailDomain, setEmailDomain] = useState('');
|
|
||||||
const [customDomain, setCustomDomain] = useState('');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="font-semibold">시안 4: 드롭다운 + 직접입력 시 인풋으로 교체 (동일 너비)</h3>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Input
|
|
||||||
placeholder="이메일"
|
|
||||||
value={emailId}
|
|
||||||
onChange={(e) => setEmailId(e.target.value)}
|
|
||||||
className="w-[140px] h-11"
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground shrink-0">@</span>
|
|
||||||
<div className="flex-1 flex gap-1">
|
|
||||||
{emailDomain === '직접입력' ? (
|
|
||||||
<Input
|
|
||||||
placeholder="도메인 입력"
|
|
||||||
value={customDomain}
|
|
||||||
onChange={(e) => setCustomDomain(e.target.value)}
|
|
||||||
className="flex-1 h-11"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1" />
|
|
||||||
)}
|
|
||||||
<Select
|
|
||||||
value={emailDomain}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setEmailDomain(v);
|
|
||||||
if (v !== '직접입력') setCustomDomain('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className={emailDomain === '직접입력' ? "w-[100px] h-11" : "w-full h-11"}>
|
|
||||||
<SelectValue placeholder="도메인 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{emailDomains.map((domain) => (
|
|
||||||
<SelectItem key={domain} value={domain}>
|
|
||||||
{domain}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
결과: {emailId}@{emailDomain === '직접입력' ? customDomain : emailDomain}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시안 5: 인풋과 드롭다운 자연스럽게 통합 (하나의 필드처럼 보이게)
|
|
||||||
function EmailDomain5() {
|
|
||||||
const [emailId, setEmailId] = useState('');
|
|
||||||
const [emailDomain, setEmailDomain] = useState('');
|
|
||||||
const [customDomain, setCustomDomain] = useState('');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="font-semibold">시안 5: 인풋 + 드롭다운 자연스럽게 통합</h3>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Input
|
|
||||||
placeholder="이메일"
|
|
||||||
value={emailId}
|
|
||||||
onChange={(e) => setEmailId(e.target.value)}
|
|
||||||
className="flex-1 h-11"
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground shrink-0">@</span>
|
|
||||||
<div className="flex items-center flex-1 h-11 border rounded-md focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
|
|
||||||
{emailDomain === '직접입력' ? (
|
|
||||||
<Input
|
|
||||||
placeholder="도메인 입력"
|
|
||||||
value={customDomain}
|
|
||||||
onChange={(e) => setCustomDomain(e.target.value)}
|
|
||||||
className="flex-1 h-full border-0 focus-visible:ring-0 focus-visible:ring-offset-0 rounded-r-none"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="flex-1 px-3 text-sm truncate">
|
|
||||||
{emailDomain || <span className="text-muted-foreground">도메인 선택</span>}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Select
|
|
||||||
value={emailDomain}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setEmailDomain(v);
|
|
||||||
if (v !== '직접입력') setCustomDomain('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-10 h-full border-0 bg-transparent px-0 focus:ring-0 rounded-l-none justify-center" />
|
|
||||||
<SelectContent>
|
|
||||||
{emailDomains.map((domain) => (
|
|
||||||
<SelectItem key={domain} value={domain}>
|
|
||||||
{domain}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
결과: {emailId}@{emailDomain === '직접입력' ? customDomain : emailDomain}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EmailDomainDemo() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-100 p-8">
|
|
||||||
<div className="max-w-2xl mx-auto space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">이메일 도메인 입력 UI 시안</h1>
|
|
||||||
<p className="text-muted-foreground mt-2">
|
|
||||||
직접입력 선택 시 인풋과 드롭다운이 함께 동작하는 다양한 방식
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-lg border space-y-8">
|
|
||||||
<EmailDomain1 />
|
|
||||||
<hr />
|
|
||||||
<EmailDomain2 />
|
|
||||||
<hr />
|
|
||||||
<EmailDomain3 />
|
|
||||||
<hr />
|
|
||||||
<EmailDomain4 />
|
|
||||||
<hr />
|
|
||||||
<EmailDomain5 />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,256 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { LayoutDashboard, Database, ChevronRight } from 'lucide-react';
|
|
||||||
|
|
||||||
// 사이드바 색상 조합 데모
|
|
||||||
export default function SidebarColorsDemo() {
|
|
||||||
const colorSchemes = [
|
|
||||||
{
|
|
||||||
name: '현재 스타일 (진한 파란색)',
|
|
||||||
sidebar: 'bg-[#1f3a8f]',
|
|
||||||
header: 'bg-white',
|
|
||||||
activeMenu: 'bg-white text-slate-800',
|
|
||||||
inactiveMenu: 'text-white hover:bg-white/20',
|
|
||||||
description: '강한 대비, 전문적인 느낌'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '밝은 회색 (Notion 스타일)',
|
|
||||||
sidebar: 'bg-slate-100',
|
|
||||||
header: 'bg-white',
|
|
||||||
activeMenu: 'bg-white text-slate-800 shadow-sm',
|
|
||||||
inactiveMenu: 'text-slate-600 hover:bg-slate-200',
|
|
||||||
description: '깔끔하고 모던한 느낌'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '진한 네이비',
|
|
||||||
sidebar: 'bg-slate-900',
|
|
||||||
header: 'bg-white',
|
|
||||||
activeMenu: 'bg-white text-slate-800',
|
|
||||||
inactiveMenu: 'text-slate-300 hover:bg-slate-800',
|
|
||||||
description: '고급스럽고 세련된 느낌'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '연한 파란색',
|
|
||||||
sidebar: 'bg-blue-50',
|
|
||||||
header: 'bg-white',
|
|
||||||
activeMenu: 'bg-white text-blue-900 shadow-sm',
|
|
||||||
inactiveMenu: 'text-blue-800 hover:bg-blue-100',
|
|
||||||
description: '부드럽고 친근한 느낌'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '흰색 + 파란 강조',
|
|
||||||
sidebar: 'bg-white border-r border-slate-200',
|
|
||||||
header: 'bg-white',
|
|
||||||
activeMenu: 'bg-blue-50 text-blue-700 border-l-2 border-blue-600',
|
|
||||||
inactiveMenu: 'text-slate-600 hover:bg-slate-50',
|
|
||||||
description: 'Linear/Vercel 스타일'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '그라데이션 (파란색)',
|
|
||||||
sidebar: 'bg-gradient-to-b from-blue-800 to-blue-900',
|
|
||||||
header: 'bg-white',
|
|
||||||
activeMenu: 'bg-white text-slate-800',
|
|
||||||
inactiveMenu: 'text-blue-100 hover:bg-white/10',
|
|
||||||
description: '현대적이고 세련된 느낌'
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-slate-100 p-8">
|
|
||||||
<h1 className="text-2xl font-bold text-slate-800 mb-2">사이드바 색상 조합 데모</h1>
|
|
||||||
<p className="text-slate-600 mb-8">로그인 페이지(흰색 배경)와 어울리는 사이드바 스타일 비교</p>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{colorSchemes.map((scheme, index) => (
|
|
||||||
<div key={index} className="bg-white rounded-xl shadow-lg overflow-hidden">
|
|
||||||
{/* 미니 레이아웃 프리뷰 */}
|
|
||||||
<div className="h-64 flex">
|
|
||||||
{/* 사이드바 */}
|
|
||||||
<div className={`w-48 flex flex-col ${scheme.sidebar}`}>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className={`p-3 ${scheme.header}`}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center">
|
|
||||||
<span className="text-white text-xs font-bold">H</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-semibold text-slate-800">한우 유전체</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 메뉴 */}
|
|
||||||
<div className="flex-1 p-2 space-y-1">
|
|
||||||
{/* 활성 메뉴 */}
|
|
||||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${scheme.activeMenu}`}>
|
|
||||||
<LayoutDashboard className="w-4 h-4" />
|
|
||||||
<span>대시보드</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 비활성 메뉴 */}
|
|
||||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${scheme.inactiveMenu}`}>
|
|
||||||
<Database className="w-4 h-4" />
|
|
||||||
<span>개체 조회</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 메인 콘텐츠 영역 */}
|
|
||||||
<div className="flex-1 bg-gradient-to-br from-blue-50/30 via-white to-amber-50/20 p-4">
|
|
||||||
<div className="h-full flex items-center justify-center">
|
|
||||||
<div className="text-center text-slate-400 text-xs">
|
|
||||||
메인 콘텐츠
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 정보 */}
|
|
||||||
<div className="p-4 border-t border-slate-100">
|
|
||||||
<h3 className="font-semibold text-slate-800">{scheme.name}</h3>
|
|
||||||
<p className="text-sm text-slate-500 mt-1">{scheme.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 헤더 연결 스타일 비교 */}
|
|
||||||
<h2 className="text-xl font-bold text-slate-800 mt-12 mb-6">헤더 연결 스타일 비교</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{/* 현재: 흰색 헤더 + 파란 사이드바 */}
|
|
||||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
|
||||||
<div className="h-72 flex">
|
|
||||||
<div className="w-56 flex flex-col">
|
|
||||||
{/* 흰색 헤더 */}
|
|
||||||
<div className="p-4 bg-white h-16 flex items-center border-b border-slate-100">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
|
|
||||||
<span className="text-white text-sm font-bold">H</span>
|
|
||||||
</div>
|
|
||||||
<span className="font-semibold text-slate-800">한우 유전체 분석</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 파란 콘텐츠 */}
|
|
||||||
<div className="flex-1 bg-[#1f3a8f] p-2 space-y-1">
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2.5 bg-white rounded-none text-slate-800 text-sm">
|
|
||||||
<LayoutDashboard className="w-4 h-4" />
|
|
||||||
<span>대시보드</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2.5 text-white text-sm hover:bg-white/20 rounded-md">
|
|
||||||
<Database className="w-4 h-4" />
|
|
||||||
<span>개체 조회</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 bg-gradient-to-br from-blue-50/30 via-white to-amber-50/20"></div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 border-t border-slate-100">
|
|
||||||
<h3 className="font-semibold text-slate-800">현재 스타일</h3>
|
|
||||||
<p className="text-sm text-slate-500 mt-1">흰색 헤더 → 파란 사이드바, 활성 메뉴 흰색</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 대안: 전체 밝은 톤 */}
|
|
||||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
|
||||||
<div className="h-72 flex">
|
|
||||||
<div className="w-56 flex flex-col bg-slate-50 border-r border-slate-200">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="p-4 bg-white h-16 flex items-center">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
|
|
||||||
<span className="text-white text-sm font-bold">H</span>
|
|
||||||
</div>
|
|
||||||
<span className="font-semibold text-slate-800">한우 유전체 분석</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 콘텐츠 */}
|
|
||||||
<div className="flex-1 p-2 space-y-1">
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2.5 bg-white text-slate-800 text-sm shadow-sm rounded-md">
|
|
||||||
<LayoutDashboard className="w-4 h-4 text-blue-600" />
|
|
||||||
<span className="font-medium">대시보드</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2.5 text-slate-600 text-sm hover:bg-slate-100 rounded-md">
|
|
||||||
<Database className="w-4 h-4" />
|
|
||||||
<span>개체 조회</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 bg-gradient-to-br from-blue-50/30 via-white to-amber-50/20"></div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 border-t border-slate-100">
|
|
||||||
<h3 className="font-semibold text-slate-800">밝은 톤 스타일</h3>
|
|
||||||
<p className="text-sm text-slate-500 mt-1">전체 밝은 톤, 로그인 페이지와 자연스러운 연결</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 대안: 파란 헤더 + 파란 사이드바 */}
|
|
||||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
|
||||||
<div className="h-72 flex">
|
|
||||||
<div className="w-56 flex flex-col bg-[#1f3a8f]">
|
|
||||||
{/* 파란 헤더 */}
|
|
||||||
<div className="p-4 h-16 flex items-center border-b border-white/10">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-8 h-8 bg-white rounded-full flex items-center justify-center">
|
|
||||||
<span className="text-blue-600 text-sm font-bold">H</span>
|
|
||||||
</div>
|
|
||||||
<span className="font-semibold text-white">한우 유전체 분석</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 콘텐츠 */}
|
|
||||||
<div className="flex-1 p-2 space-y-1">
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2.5 bg-white/20 text-white text-sm rounded-md">
|
|
||||||
<LayoutDashboard className="w-4 h-4" />
|
|
||||||
<span className="font-medium">대시보드</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2.5 text-white/70 text-sm hover:bg-white/10 rounded-md">
|
|
||||||
<Database className="w-4 h-4" />
|
|
||||||
<span>개체 조회</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 bg-gradient-to-br from-blue-50/30 via-white to-amber-50/20"></div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 border-t border-slate-100">
|
|
||||||
<h3 className="font-semibold text-slate-800">전체 파란색 스타일</h3>
|
|
||||||
<p className="text-sm text-slate-500 mt-1">통일된 파란색, 강한 브랜드 아이덴티티</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 대안: 왼쪽 강조선 */}
|
|
||||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
|
||||||
<div className="h-72 flex">
|
|
||||||
<div className="w-56 flex flex-col bg-white border-r border-slate-200">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="p-4 h-16 flex items-center">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
|
|
||||||
<span className="text-white text-sm font-bold">H</span>
|
|
||||||
</div>
|
|
||||||
<span className="font-semibold text-slate-800">한우 유전체 분석</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 콘텐츠 */}
|
|
||||||
<div className="flex-1 p-2 space-y-1">
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2.5 bg-blue-50 text-blue-700 text-sm border-l-3 border-blue-600 rounded-r-md">
|
|
||||||
<LayoutDashboard className="w-4 h-4" />
|
|
||||||
<span className="font-medium">대시보드</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2.5 text-slate-600 text-sm hover:bg-slate-50 rounded-md">
|
|
||||||
<Database className="w-4 h-4" />
|
|
||||||
<span>개체 조회</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 bg-gradient-to-br from-blue-50/30 via-white to-amber-50/20"></div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 border-t border-slate-100">
|
|
||||||
<h3 className="font-semibold text-slate-800">왼쪽 강조선 스타일</h3>
|
|
||||||
<p className="text-sm text-slate-500 mt-1">미니멀하고 깔끔한 네비게이션</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "pretendard/dist/web/static/pretendard-dynamic-subset.css";
|
import "pretendard/dist/web/static/pretendard-dynamic-subset.css";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { GlobalFilterProvider } from "@/contexts/GlobalFilterContext";
|
|
||||||
import { AnalysisYearProvider } from "@/contexts/AnalysisYearContext";
|
import { AnalysisYearProvider } from "@/contexts/AnalysisYearContext";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
@@ -18,12 +17,10 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<body className="antialiased" style={{ fontFamily: 'Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif' }}>
|
<body className="antialiased" style={{ fontFamily: 'Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif' }}>
|
||||||
<GlobalFilterProvider>
|
|
||||||
<AnalysisYearProvider>
|
<AnalysisYearProvider>
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</AnalysisYearProvider>
|
</AnalysisYearProvider>
|
||||||
</GlobalFilterProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,392 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useSearchParams, useRouter } from "next/navigation"
|
|
||||||
import { AppSidebar } from "@/components/layout/app-sidebar"
|
|
||||||
import { SiteHeader } from "@/components/layout/site-header"
|
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { useToast } from "@/hooks/use-toast"
|
|
||||||
import { mptApi, MptDto, cowApi } from "@/lib/api"
|
|
||||||
import { CowDetail } from "@/types/cow.types"
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Activity,
|
|
||||||
Search,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
|
||||||
import { AuthGuard } from "@/components/auth/auth-guard"
|
|
||||||
|
|
||||||
// 혈액화학검사 항목별 참조값 (정상 범위)
|
|
||||||
const MPT_REFERENCE_VALUES: Record<string, { min: number; max: number; unit: string; name: string }> = {
|
|
||||||
// 에너지 대사
|
|
||||||
glucose: { min: 45, max: 75, unit: 'mg/dL', name: '혈당' },
|
|
||||||
cholesterol: { min: 80, max: 120, unit: 'mg/dL', name: '콜레스테롤' },
|
|
||||||
nefa: { min: 0, max: 0.4, unit: 'mEq/L', name: 'NEFA(유리지방산)' },
|
|
||||||
bcs: { min: 2.5, max: 3.5, unit: '점', name: 'BCS' },
|
|
||||||
// 단백질 대사
|
|
||||||
totalProtein: { min: 6.5, max: 8.5, unit: 'g/dL', name: '총단백질' },
|
|
||||||
albumin: { min: 3.0, max: 3.6, unit: 'g/dL', name: '알부민' },
|
|
||||||
globulin: { min: 3.0, max: 5.0, unit: 'g/dL', name: '글로불린' },
|
|
||||||
agRatio: { min: 0.6, max: 1.2, unit: '', name: 'A/G 비율' },
|
|
||||||
bun: { min: 8, max: 25, unit: 'mg/dL', name: 'BUN(요소태질소)' },
|
|
||||||
// 간기능
|
|
||||||
ast: { min: 45, max: 110, unit: 'U/L', name: 'AST' },
|
|
||||||
ggt: { min: 10, max: 36, unit: 'U/L', name: 'GGT' },
|
|
||||||
fattyLiverIdx: { min: 0, max: 30, unit: '', name: '지방간지수' },
|
|
||||||
// 미네랄
|
|
||||||
calcium: { min: 8.5, max: 11.5, unit: 'mg/dL', name: '칼슘' },
|
|
||||||
phosphorus: { min: 4.0, max: 7.5, unit: 'mg/dL', name: '인' },
|
|
||||||
caPRatio: { min: 1.0, max: 2.0, unit: '', name: 'Ca/P 비율' },
|
|
||||||
magnesium: { min: 1.8, max: 2.5, unit: 'mg/dL', name: '마그네슘' },
|
|
||||||
creatine: { min: 1.0, max: 2.0, unit: 'mg/dL', name: '크레아틴' },
|
|
||||||
}
|
|
||||||
|
|
||||||
// 카테고리별 항목 그룹핑
|
|
||||||
const MPT_CATEGORIES = [
|
|
||||||
{
|
|
||||||
name: '에너지 대사',
|
|
||||||
items: ['glucose', 'cholesterol', 'nefa', 'bcs'],
|
|
||||||
color: 'bg-orange-500',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '단백질 대사',
|
|
||||||
items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'],
|
|
||||||
color: 'bg-blue-500',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '간기능',
|
|
||||||
items: ['ast', 'ggt', 'fattyLiverIdx'],
|
|
||||||
color: 'bg-green-500',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '미네랄',
|
|
||||||
items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium', 'creatine'],
|
|
||||||
color: 'bg-purple-500',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// 측정값 상태 판정 (정상/주의/위험)
|
|
||||||
function getValueStatus(key: string, value: number | null): 'normal' | 'warning' | 'danger' | 'unknown' {
|
|
||||||
if (value === null || value === undefined) return 'unknown'
|
|
||||||
const ref = MPT_REFERENCE_VALUES[key]
|
|
||||||
if (!ref) return 'unknown'
|
|
||||||
|
|
||||||
if (value >= ref.min && value <= ref.max) return 'normal'
|
|
||||||
|
|
||||||
// 10% 이내 범위 이탈은 주의
|
|
||||||
const margin = (ref.max - ref.min) * 0.1
|
|
||||||
if (value >= ref.min - margin && value <= ref.max + margin) return 'warning'
|
|
||||||
|
|
||||||
return 'danger'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MptPage() {
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const cowShortNo = searchParams.get('cowShortNo')
|
|
||||||
const farmNo = searchParams.get('farmNo')
|
|
||||||
const { toast } = useToast()
|
|
||||||
|
|
||||||
const [searchInput, setSearchInput] = useState(cowShortNo || '')
|
|
||||||
const [mptData, setMptData] = useState<MptDto[]>([])
|
|
||||||
const [selectedMpt, setSelectedMpt] = useState<MptDto | null>(null)
|
|
||||||
const [cow, setCow] = useState<CowDetail | null>(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
// 검색 실행
|
|
||||||
const handleSearch = async () => {
|
|
||||||
if (!searchInput.trim()) {
|
|
||||||
toast({
|
|
||||||
title: '검색어를 입력해주세요',
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const data = await mptApi.findByCowShortNo(searchInput.trim())
|
|
||||||
setMptData(data)
|
|
||||||
if (data.length > 0) {
|
|
||||||
setSelectedMpt(data[0]) // 가장 최근 검사 결과 선택
|
|
||||||
} else {
|
|
||||||
setSelectedMpt(null)
|
|
||||||
toast({
|
|
||||||
title: '검사 결과가 없습니다',
|
|
||||||
description: `개체번호 ${searchInput}의 혈액화학검사 결과를 찾을 수 없습니다.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('MPT 데이터 로드 실패:', error)
|
|
||||||
toast({
|
|
||||||
title: '데이터 로드 실패',
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 초기 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (cowShortNo) {
|
|
||||||
handleSearch()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
router.back()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthGuard>
|
|
||||||
<SidebarProvider>
|
|
||||||
<AppSidebar />
|
|
||||||
<SidebarInset>
|
|
||||||
<SiteHeader />
|
|
||||||
<main className="flex-1 overflow-y-auto bg-white min-h-screen">
|
|
||||||
<div className="w-full p-6 sm:px-6 lg:px-8 sm:py-6 space-y-6">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2 sm:gap-4">
|
|
||||||
<Button
|
|
||||||
onClick={handleBack}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-muted gap-1.5 -ml-2 px-2 sm:px-3"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-7 w-7 sm:h-4 sm:w-4" />
|
|
||||||
<span className="hidden sm:inline text-sm">뒤로가기</span>
|
|
||||||
</Button>
|
|
||||||
<div className="w-10 h-10 sm:w-14 sm:h-14 bg-primary rounded-xl flex items-center justify-center flex-shrink-0">
|
|
||||||
<Activity className="h-5 w-5 sm:h-7 sm:w-7 text-primary-foreground" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-lg sm:text-3xl lg:text-4xl font-bold text-foreground">혈액화학검사</h1>
|
|
||||||
<p className="text-sm sm:text-lg text-muted-foreground">Metabolic Profile Test</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 검색 영역 */}
|
|
||||||
<Card className="bg-white border border-border shadow-sm rounded-2xl">
|
|
||||||
<CardContent className="p-4 sm:p-6">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Input
|
|
||||||
placeholder="개체 요약번호 입력 (예: 4049)"
|
|
||||||
value={searchInput}
|
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button onClick={handleSearch} disabled={loading}>
|
|
||||||
<Search className="h-4 w-4 mr-2" />
|
|
||||||
검색
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
|
||||||
<p className="text-muted-foreground">데이터를 불러오는 중...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && selectedMpt && (
|
|
||||||
<>
|
|
||||||
{/* 개체 정보 */}
|
|
||||||
<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">
|
|
||||||
<span className="text-2xl font-bold text-foreground">{selectedMpt.cowShortNo || '-'}</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">
|
|
||||||
{selectedMpt.testDt ? new Date(selectedMpt.testDt).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">
|
|
||||||
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'}
|
|
||||||
</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">
|
|
||||||
{selectedMpt.parity ? `${selectedMpt.parity}산차` : '-'}
|
|
||||||
</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>
|
|
||||||
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">{selectedMpt.cowShortNo || '-'}</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">
|
|
||||||
{selectedMpt.testDt ? new Date(selectedMpt.testDt).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">
|
|
||||||
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'}
|
|
||||||
</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">
|
|
||||||
{selectedMpt.parity ? `${selectedMpt.parity}산차` : '-'}
|
|
||||||
</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="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-muted/50 border-b border-border">
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-muted-foreground w-28">카테고리</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-muted-foreground">검사항목</th>
|
|
||||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-24">측정값</th>
|
|
||||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20">하한값</th>
|
|
||||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20">상한값</th>
|
|
||||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20">단위</th>
|
|
||||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20">상태</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{MPT_CATEGORIES.map((category, catIdx) => (
|
|
||||||
category.items.map((itemKey, itemIdx) => {
|
|
||||||
const ref = MPT_REFERENCE_VALUES[itemKey]
|
|
||||||
const value = selectedMpt[itemKey as keyof MptDto] as number | null
|
|
||||||
const status = getValueStatus(itemKey, value)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={itemKey} className="border-b border-border hover:bg-muted/30">
|
|
||||||
{itemIdx === 0 && (
|
|
||||||
<td
|
|
||||||
rowSpan={category.items.length}
|
|
||||||
className={`px-4 py-3 text-sm font-semibold text-white ${category.color} align-middle text-center`}
|
|
||||||
>
|
|
||||||
{category.name}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
<td className="px-4 py-3 text-sm font-medium text-foreground">{ref?.name || itemKey}</td>
|
|
||||||
<td className="px-4 py-3 text-center">
|
|
||||||
<span className={`text-lg font-bold ${
|
|
||||||
status === 'normal' ? 'text-green-600' :
|
|
||||||
status === 'warning' ? 'text-amber-600' :
|
|
||||||
status === 'danger' ? 'text-red-600' :
|
|
||||||
'text-muted-foreground'
|
|
||||||
}`}>
|
|
||||||
{value !== null && value !== undefined ? value.toFixed(2) : '-'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.min ?? '-'}</td>
|
|
||||||
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.max ?? '-'}</td>
|
|
||||||
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.unit || '-'}</td>
|
|
||||||
<td className="px-4 py-3 text-center">
|
|
||||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold ${
|
|
||||||
status === 'normal' ? 'bg-green-100 text-green-700' :
|
|
||||||
status === 'warning' ? 'bg-amber-100 text-amber-700' :
|
|
||||||
status === 'danger' ? 'bg-red-100 text-red-700' :
|
|
||||||
'bg-slate-100 text-slate-500'
|
|
||||||
}`}>
|
|
||||||
{status === 'normal' ? '정상' :
|
|
||||||
status === 'warning' ? '주의' :
|
|
||||||
status === 'danger' ? '이상' : '-'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 검사 이력 (여러 검사 결과가 있을 경우) */}
|
|
||||||
{mptData.length > 1 && (
|
|
||||||
<>
|
|
||||||
<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-4">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{mptData.map((mpt, idx) => (
|
|
||||||
<Button
|
|
||||||
key={mpt.pkMptNo}
|
|
||||||
variant={selectedMpt?.pkMptNo === mpt.pkMptNo ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setSelectedMpt(mpt)}
|
|
||||||
>
|
|
||||||
{mpt.testDt ? new Date(mpt.testDt).toLocaleDateString('ko-KR') : `검사 ${idx + 1}`}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !selectedMpt && cowShortNo && (
|
|
||||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
|
||||||
<Activity className="h-16 w-16 text-muted-foreground/30 mb-4" />
|
|
||||||
<p className="text-lg font-medium text-muted-foreground">검사 결과가 없습니다</p>
|
|
||||||
<p className="text-sm text-muted-foreground">해당 개체의 혈액화학검사 결과를 찾을 수 없습니다.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !selectedMpt && !cowShortNo && (
|
|
||||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
|
||||||
<Search className="h-16 w-16 text-muted-foreground/30 mb-4" />
|
|
||||||
<p className="text-lg font-medium text-muted-foreground">개체번호를 검색해주세요</p>
|
|
||||||
<p className="text-sm text-muted-foreground">개체 요약번호를 입력하여 혈액화학검사 결과를 조회합니다.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</SidebarInset>
|
|
||||||
</SidebarProvider>
|
|
||||||
</AuthGuard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,7 @@ export default function Home() {
|
|||||||
if (isAuthenticated && accessToken) {
|
if (isAuthenticated && accessToken) {
|
||||||
try {
|
try {
|
||||||
// 프로필 로드로 토큰 유효성 확인
|
// 프로필 로드로 토큰 유효성 확인
|
||||||
// await loadProfile(); // 👈 주석처리: 백엔드 /users/profile 미구현으로 인한 401 에러 방지
|
// await loadProfile(); // 백엔드 /users/profile 미구현으로 인한 401 에러 방지
|
||||||
// 성공하면 대시보드로
|
// 성공하면 대시보드로
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||||
import { Settings2, Filter, X, Search, ChevronDown, ChevronUp, Loader2, Plus, Pin, GripVertical } from "lucide-react"
|
import { Settings2, Filter, X, Search, ChevronDown, ChevronUp, Loader2, Plus, Pin, GripVertical } from "lucide-react"
|
||||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
import { useFilterStore } from "@/store/filter-store"
|
||||||
import { DEFAULT_FILTER_SETTINGS } from "@/types/filter.types"
|
import { DEFAULT_FILTER_SETTINGS } from "@/types/filter.types"
|
||||||
import { geneApi, type MarkerModel } from "@/lib/api/gene.api"
|
import { TRAIT_CATEGORY_LIST as TRAIT_CATEGORIES, TRAIT_DESCRIPTIONS } from "@/constants/traits"
|
||||||
|
import { geneApi } from "@/lib/api/gene.api"
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
@@ -173,54 +174,6 @@ function SortableTraitItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 형질 카테고리 정의
|
|
||||||
const TRAIT_CATEGORIES = [
|
|
||||||
{ id: 'growth', name: '성장형질', traits: ['12개월령체중'] },
|
|
||||||
{ id: 'economic', name: '경제형질', traits: ['도체중', '등심단면적', '등지방두께', '근내지방도'] },
|
|
||||||
{ id: 'body', name: '체형형질', traits: ['체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위'] },
|
|
||||||
{ id: 'weight', name: '부위별무게', traits: ['안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight', '우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight'] },
|
|
||||||
{ id: 'rate', name: '부위별비율', traits: ['안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate', '우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate'] },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 형질 설명
|
|
||||||
const TRAIT_DESCRIPTIONS: Record<string, string> = {
|
|
||||||
'12개월령체중': '12개월 시점 체중',
|
|
||||||
'도체중': '도축 후 고기 무게',
|
|
||||||
'등심단면적': '등심 부위 크기',
|
|
||||||
'등지방두께': '등 부위 지방 두께',
|
|
||||||
'근내지방도': '마블링 정도',
|
|
||||||
'체고': '어깨 높이',
|
|
||||||
'십자': '엉덩이뼈 높이',
|
|
||||||
'체장': '몸통 길이',
|
|
||||||
'흉심': '가슴 깊이',
|
|
||||||
'흉폭': '가슴 너비',
|
|
||||||
'고장': '허리뼈 길이',
|
|
||||||
'요각폭': '허리뼈 너비',
|
|
||||||
'좌골폭': '엉덩이뼈 너비',
|
|
||||||
'곤폭': '엉덩이뼈 끝 너비',
|
|
||||||
'흉위': '가슴 둘레',
|
|
||||||
'안심weight': '안심 부위 무게',
|
|
||||||
'등심weight': '등심 부위 무게',
|
|
||||||
'채끝weight': '채끝 부위 무게',
|
|
||||||
'목심weight': '목심 부위 무게',
|
|
||||||
'앞다리weight': '앞다리 부위 무게',
|
|
||||||
'우둔weight': '우둔 부위 무게',
|
|
||||||
'설도weight': '설도 부위 무게',
|
|
||||||
'사태weight': '사태 부위 무게',
|
|
||||||
'양지weight': '양지 부위 무게',
|
|
||||||
'갈비weight': '갈비 부위 무게',
|
|
||||||
'안심rate': '전체 대비 안심 비율',
|
|
||||||
'등심rate': '전체 대비 등심 비율',
|
|
||||||
'채끝rate': '전체 대비 채끝 비율',
|
|
||||||
'목심rate': '전체 대비 목심 비율',
|
|
||||||
'앞다리rate': '전체 대비 앞다리 비율',
|
|
||||||
'우둔rate': '전체 대비 우둔 비율',
|
|
||||||
'설도rate': '전체 대비 설도 비율',
|
|
||||||
'사태rate': '전체 대비 사태 비율',
|
|
||||||
'양지rate': '전체 대비 양지 비율',
|
|
||||||
'갈비rate': '전체 대비 갈비 비율',
|
|
||||||
}
|
|
||||||
|
|
||||||
// 형질 표시 이름 (DB 키 -> 화면 표시용)
|
// 형질 표시 이름 (DB 키 -> 화면 표시용)
|
||||||
const TRAIT_DISPLAY_NAMES: Record<string, string> = {
|
const TRAIT_DISPLAY_NAMES: Record<string, string> = {
|
||||||
'안심weight': '안심중량',
|
'안심weight': '안심중량',
|
||||||
@@ -255,7 +208,7 @@ interface GlobalFilterDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCount = 0, traitCount = 0 }: GlobalFilterDialogProps = {}) {
|
export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCount = 0, traitCount = 0 }: GlobalFilterDialogProps = {}) {
|
||||||
const { filters, updateFilters, resetFilters } = useGlobalFilter()
|
const { filters, updateFilters, resetFilters } = useFilterStore()
|
||||||
const [internalOpen, setInternalOpen] = useState(false)
|
const [internalOpen, setInternalOpen] = useState(false)
|
||||||
|
|
||||||
const open = externalOpen !== undefined ? externalOpen : internalOpen
|
const open = externalOpen !== undefined ? externalOpen : internalOpen
|
||||||
@@ -271,8 +224,8 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCou
|
|||||||
const [openCategories, setOpenCategories] = useState<Record<string, boolean>>({})
|
const [openCategories, setOpenCategories] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
// DB에서 가져온 유전자 목록
|
// DB에서 가져온 유전자 목록
|
||||||
const [quantityGenes, setQuantityGenes] = useState<MarkerModel[]>([])
|
const [quantityGenes, setQuantityGenes] = useState<any[]>([])
|
||||||
const [qualityGenes, setQualityGenes] = useState<MarkerModel[]>([])
|
const [qualityGenes, setQualityGenes] = useState<any[]>([])
|
||||||
const [loadingGenes, setLoadingGenes] = useState(false)
|
const [loadingGenes, setLoadingGenes] = useState(false)
|
||||||
|
|
||||||
// 로컬 상태
|
// 로컬 상태
|
||||||
@@ -434,7 +387,7 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCou
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 전체 선택/해제
|
// 카테고리 전체 선택/해제
|
||||||
const toggleCategoryGenes = (genes: MarkerModel[], select: boolean) => {
|
const toggleCategoryGenes = (genes: any[], select: boolean) => {
|
||||||
setLocalFilters(prev => {
|
setLocalFilters(prev => {
|
||||||
if (select) {
|
if (select) {
|
||||||
const newGenes = [...prev.selectedGenes]
|
const newGenes = [...prev.selectedGenes]
|
||||||
@@ -938,14 +891,14 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCou
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{TRAIT_CATEGORIES.map(cat => {
|
{TRAIT_CATEGORIES.map(cat => {
|
||||||
const isCategoryMatch = traitSearch && cat.name.toLowerCase().includes(traitSearch.toLowerCase())
|
const isCategoryMatch = traitSearch && cat.name.toLowerCase().includes(traitSearch.toLowerCase())
|
||||||
const filteredTraits = traitSearch
|
const filteredTraits: string[] = traitSearch
|
||||||
? isCategoryMatch
|
? isCategoryMatch
|
||||||
? cat.traits
|
? [...cat.traits]
|
||||||
: cat.traits.filter(t =>
|
: [...cat.traits].filter(t =>
|
||||||
t.toLowerCase().includes(traitSearch.toLowerCase()) ||
|
t.toLowerCase().includes(traitSearch.toLowerCase()) ||
|
||||||
(TRAIT_DESCRIPTIONS[t] && TRAIT_DESCRIPTIONS[t].toLowerCase().includes(traitSearch.toLowerCase()))
|
(TRAIT_DESCRIPTIONS[t] && TRAIT_DESCRIPTIONS[t].toLowerCase().includes(traitSearch.toLowerCase()))
|
||||||
)
|
)
|
||||||
: cat.traits
|
: [...cat.traits]
|
||||||
|
|
||||||
if (traitSearch && filteredTraits.length === 0) return null
|
if (traitSearch && filteredTraits.length === 0) return null
|
||||||
|
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card"
|
|
||||||
import {
|
|
||||||
ChartConfig,
|
|
||||||
ChartContainer,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
} from "@/components/ui/chart"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import {
|
|
||||||
ToggleGroup,
|
|
||||||
ToggleGroupItem,
|
|
||||||
} from "@/components/ui/toggle-group"
|
|
||||||
|
|
||||||
export const description = "An interactive area chart"
|
|
||||||
|
|
||||||
const chartData = [
|
|
||||||
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
|
||||||
{ date: "2024-04-02", desktop: 97, mobile: 180 },
|
|
||||||
{ date: "2024-04-03", desktop: 167, mobile: 120 },
|
|
||||||
{ date: "2024-04-04", desktop: 242, mobile: 260 },
|
|
||||||
{ date: "2024-04-05", desktop: 373, mobile: 290 },
|
|
||||||
{ date: "2024-04-06", desktop: 301, mobile: 340 },
|
|
||||||
{ date: "2024-04-07", desktop: 245, mobile: 180 },
|
|
||||||
{ date: "2024-04-08", desktop: 409, mobile: 320 },
|
|
||||||
{ date: "2024-04-09", desktop: 59, mobile: 110 },
|
|
||||||
{ date: "2024-04-10", desktop: 261, mobile: 190 },
|
|
||||||
{ date: "2024-04-11", desktop: 327, mobile: 350 },
|
|
||||||
{ date: "2024-04-12", desktop: 292, mobile: 210 },
|
|
||||||
{ date: "2024-04-13", desktop: 342, mobile: 380 },
|
|
||||||
{ date: "2024-04-14", desktop: 137, mobile: 220 },
|
|
||||||
{ date: "2024-04-15", desktop: 120, mobile: 170 },
|
|
||||||
{ date: "2024-04-16", desktop: 138, mobile: 190 },
|
|
||||||
{ date: "2024-04-17", desktop: 446, mobile: 360 },
|
|
||||||
{ date: "2024-04-18", desktop: 364, mobile: 410 },
|
|
||||||
{ date: "2024-04-19", desktop: 243, mobile: 180 },
|
|
||||||
{ date: "2024-04-20", desktop: 89, mobile: 150 },
|
|
||||||
{ date: "2024-04-21", desktop: 137, mobile: 200 },
|
|
||||||
{ date: "2024-04-22", desktop: 224, mobile: 170 },
|
|
||||||
{ date: "2024-04-23", desktop: 138, mobile: 230 },
|
|
||||||
{ date: "2024-04-24", desktop: 387, mobile: 290 },
|
|
||||||
{ date: "2024-04-25", desktop: 215, mobile: 250 },
|
|
||||||
{ date: "2024-04-26", desktop: 75, mobile: 130 },
|
|
||||||
{ date: "2024-04-27", desktop: 383, mobile: 420 },
|
|
||||||
{ date: "2024-04-28", desktop: 122, mobile: 180 },
|
|
||||||
{ date: "2024-04-29", desktop: 315, mobile: 240 },
|
|
||||||
{ date: "2024-04-30", desktop: 454, mobile: 380 },
|
|
||||||
{ date: "2024-05-01", desktop: 165, mobile: 220 },
|
|
||||||
{ date: "2024-05-02", desktop: 293, mobile: 310 },
|
|
||||||
{ date: "2024-05-03", desktop: 247, mobile: 190 },
|
|
||||||
{ date: "2024-05-04", desktop: 385, mobile: 420 },
|
|
||||||
{ date: "2024-05-05", desktop: 481, mobile: 390 },
|
|
||||||
{ date: "2024-05-06", desktop: 498, mobile: 520 },
|
|
||||||
{ date: "2024-05-07", desktop: 388, mobile: 300 },
|
|
||||||
{ date: "2024-05-08", desktop: 149, mobile: 210 },
|
|
||||||
{ date: "2024-05-09", desktop: 227, mobile: 180 },
|
|
||||||
{ date: "2024-05-10", desktop: 293, mobile: 330 },
|
|
||||||
{ date: "2024-05-11", desktop: 335, mobile: 270 },
|
|
||||||
{ date: "2024-05-12", desktop: 197, mobile: 240 },
|
|
||||||
{ date: "2024-05-13", desktop: 197, mobile: 160 },
|
|
||||||
{ date: "2024-05-14", desktop: 448, mobile: 490 },
|
|
||||||
{ date: "2024-05-15", desktop: 473, mobile: 380 },
|
|
||||||
{ date: "2024-05-16", desktop: 338, mobile: 400 },
|
|
||||||
{ date: "2024-05-17", desktop: 499, mobile: 420 },
|
|
||||||
{ date: "2024-05-18", desktop: 315, mobile: 350 },
|
|
||||||
{ date: "2024-05-19", desktop: 235, mobile: 180 },
|
|
||||||
{ date: "2024-05-20", desktop: 177, mobile: 230 },
|
|
||||||
{ date: "2024-05-21", desktop: 82, mobile: 140 },
|
|
||||||
{ date: "2024-05-22", desktop: 81, mobile: 120 },
|
|
||||||
{ date: "2024-05-23", desktop: 252, mobile: 290 },
|
|
||||||
{ date: "2024-05-24", desktop: 294, mobile: 220 },
|
|
||||||
{ date: "2024-05-25", desktop: 201, mobile: 250 },
|
|
||||||
{ date: "2024-05-26", desktop: 213, mobile: 170 },
|
|
||||||
{ date: "2024-05-27", desktop: 420, mobile: 460 },
|
|
||||||
{ date: "2024-05-28", desktop: 233, mobile: 190 },
|
|
||||||
{ date: "2024-05-29", desktop: 78, mobile: 130 },
|
|
||||||
{ date: "2024-05-30", desktop: 340, mobile: 280 },
|
|
||||||
{ date: "2024-05-31", desktop: 178, mobile: 230 },
|
|
||||||
{ date: "2024-06-01", desktop: 178, mobile: 200 },
|
|
||||||
{ date: "2024-06-02", desktop: 470, mobile: 410 },
|
|
||||||
{ date: "2024-06-03", desktop: 103, mobile: 160 },
|
|
||||||
{ date: "2024-06-04", desktop: 439, mobile: 380 },
|
|
||||||
{ date: "2024-06-05", desktop: 88, mobile: 140 },
|
|
||||||
{ date: "2024-06-06", desktop: 294, mobile: 250 },
|
|
||||||
{ date: "2024-06-07", desktop: 323, mobile: 370 },
|
|
||||||
{ date: "2024-06-08", desktop: 385, mobile: 320 },
|
|
||||||
{ date: "2024-06-09", desktop: 438, mobile: 480 },
|
|
||||||
{ date: "2024-06-10", desktop: 155, mobile: 200 },
|
|
||||||
{ date: "2024-06-11", desktop: 92, mobile: 150 },
|
|
||||||
{ date: "2024-06-12", desktop: 492, mobile: 420 },
|
|
||||||
{ date: "2024-06-13", desktop: 81, mobile: 130 },
|
|
||||||
{ date: "2024-06-14", desktop: 426, mobile: 380 },
|
|
||||||
{ date: "2024-06-15", desktop: 307, mobile: 350 },
|
|
||||||
{ date: "2024-06-16", desktop: 371, mobile: 310 },
|
|
||||||
{ date: "2024-06-17", desktop: 475, mobile: 520 },
|
|
||||||
{ date: "2024-06-18", desktop: 107, mobile: 170 },
|
|
||||||
{ date: "2024-06-19", desktop: 341, mobile: 290 },
|
|
||||||
{ date: "2024-06-20", desktop: 408, mobile: 450 },
|
|
||||||
{ date: "2024-06-21", desktop: 169, mobile: 210 },
|
|
||||||
{ date: "2024-06-22", desktop: 317, mobile: 270 },
|
|
||||||
{ date: "2024-06-23", desktop: 480, mobile: 530 },
|
|
||||||
{ date: "2024-06-24", desktop: 132, mobile: 180 },
|
|
||||||
{ date: "2024-06-25", desktop: 141, mobile: 190 },
|
|
||||||
{ date: "2024-06-26", desktop: 434, mobile: 380 },
|
|
||||||
{ date: "2024-06-27", desktop: 448, mobile: 490 },
|
|
||||||
{ date: "2024-06-28", desktop: 149, mobile: 200 },
|
|
||||||
{ date: "2024-06-29", desktop: 103, mobile: 160 },
|
|
||||||
{ date: "2024-06-30", desktop: 446, mobile: 400 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const chartConfig = {
|
|
||||||
visitors: {
|
|
||||||
label: "Visitors",
|
|
||||||
},
|
|
||||||
desktop: {
|
|
||||||
label: "Desktop",
|
|
||||||
color: "var(--primary)",
|
|
||||||
},
|
|
||||||
mobile: {
|
|
||||||
label: "Mobile",
|
|
||||||
color: "var(--primary)",
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig
|
|
||||||
|
|
||||||
export function ChartAreaInteractive() {
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const [timeRange, setTimeRange] = React.useState("90d")
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isMobile) {
|
|
||||||
setTimeRange("7d")
|
|
||||||
}
|
|
||||||
}, [isMobile])
|
|
||||||
|
|
||||||
const filteredData = chartData.filter((item) => {
|
|
||||||
const date = new Date(item.date)
|
|
||||||
const referenceDate = new Date("2024-06-30")
|
|
||||||
let daysToSubtract = 90
|
|
||||||
if (timeRange === "30d") {
|
|
||||||
daysToSubtract = 30
|
|
||||||
} else if (timeRange === "7d") {
|
|
||||||
daysToSubtract = 7
|
|
||||||
}
|
|
||||||
const startDate = new Date(referenceDate)
|
|
||||||
startDate.setDate(startDate.getDate() - daysToSubtract)
|
|
||||||
return date >= startDate
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="@container/card">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Total Visitors</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
<span className="hidden @[540px]/card:block">
|
|
||||||
Total for the last 3 months
|
|
||||||
</span>
|
|
||||||
<span className="@[540px]/card:hidden">Last 3 months</span>
|
|
||||||
</CardDescription>
|
|
||||||
<CardAction>
|
|
||||||
<ToggleGroup
|
|
||||||
type="single"
|
|
||||||
value={timeRange}
|
|
||||||
onValueChange={setTimeRange}
|
|
||||||
variant="outline"
|
|
||||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
|
|
||||||
>
|
|
||||||
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
|
|
||||||
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
|
|
||||||
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
|
|
||||||
</ToggleGroup>
|
|
||||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
|
||||||
<SelectTrigger
|
|
||||||
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
|
|
||||||
size="sm"
|
|
||||||
aria-label="Select a value"
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Last 3 months" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="rounded-lg">
|
|
||||||
<SelectItem value="90d" className="rounded-lg">
|
|
||||||
Last 3 months
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="30d" className="rounded-lg">
|
|
||||||
Last 30 days
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="7d" className="rounded-lg">
|
|
||||||
Last 7 days
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
|
||||||
<ChartContainer
|
|
||||||
config={chartConfig}
|
|
||||||
className="aspect-auto h-[250px] w-full"
|
|
||||||
>
|
|
||||||
<AreaChart data={filteredData}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop
|
|
||||||
offset="5%"
|
|
||||||
stopColor="var(--color-desktop)"
|
|
||||||
stopOpacity={1.0}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="95%"
|
|
||||||
stopColor="var(--color-desktop)"
|
|
||||||
stopOpacity={0.1}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop
|
|
||||||
offset="5%"
|
|
||||||
stopColor="var(--color-mobile)"
|
|
||||||
stopOpacity={0.8}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="95%"
|
|
||||||
stopColor="var(--color-mobile)"
|
|
||||||
stopOpacity={0.1}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tickMargin={8}
|
|
||||||
minTickGap={32}
|
|
||||||
tickFormatter={(value) => {
|
|
||||||
const date = new Date(value)
|
|
||||||
return date.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
cursor={false}
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
labelFormatter={(value) => {
|
|
||||||
return new Date(value).toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
indicator="dot"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
dataKey="mobile"
|
|
||||||
type="natural"
|
|
||||||
fill="url(#fillMobile)"
|
|
||||||
stroke="var(--color-mobile)"
|
|
||||||
stackId="a"
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
dataKey="desktop"
|
|
||||||
type="natural"
|
|
||||||
fill="url(#fillDesktop)"
|
|
||||||
stroke="var(--color-desktop)"
|
|
||||||
stackId="a"
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { TrendingUp, TrendingDown, Minus, Users, Heart, Activity } from "lucide-react"
|
|
||||||
import { IconTrendingUp, IconTrendingDown, IconMoodNeutral } from "@tabler/icons-react"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { dashboardApi, breedApi, cowApi } from "@/lib/api"
|
|
||||||
import { FarmSummaryDto, FarmEvaluationDto } from "@/types/dashboard.types"
|
|
||||||
import { useAuthStore } from "@/store/auth-store"
|
|
||||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
|
||||||
|
|
||||||
interface KPIData {
|
|
||||||
label: string
|
|
||||||
value: string | number
|
|
||||||
change: number
|
|
||||||
changeLabel: string
|
|
||||||
icon: React.ReactNode
|
|
||||||
color: string
|
|
||||||
badge?: React.ReactNode
|
|
||||||
subtext?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KPIDashboardProps {
|
|
||||||
farmNo: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KPIDashboard({ farmNo }: KPIDashboardProps) {
|
|
||||||
const { user } = useAuthStore()
|
|
||||||
const { filters } = useGlobalFilter()
|
|
||||||
const [kpiData, setKpiData] = useState<KPIData[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
// farmNo가 없으면 데이터 로드하지 않음
|
|
||||||
if (!farmNo) {
|
|
||||||
console.warn('farmNo가 없습니다.')
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
// 필터를 백엔드 DTO 형식으로 변환
|
|
||||||
const filterDto = {
|
|
||||||
targetGenes: filters.selectedGenes?.length > 0 ? filters.selectedGenes : undefined,
|
|
||||||
// 추후 필요시 다른 필터 추가 가능
|
|
||||||
}
|
|
||||||
|
|
||||||
// 실제 API 데이터 가져오기
|
|
||||||
const [cows, evaluationData, breedSaves] = await Promise.all([
|
|
||||||
cowApi.findByFarmNo(farmNo).catch(() => []),
|
|
||||||
dashboardApi.getFarmEvaluation(farmNo, filterDto).catch(() => null),
|
|
||||||
user
|
|
||||||
? breedApi.findByUser(user.pkUserNo).catch(() => [])
|
|
||||||
: Promise.resolve([]),
|
|
||||||
])
|
|
||||||
|
|
||||||
// 안전하게 데이터 추출
|
|
||||||
const totalCows = Array.isArray(cows) ? cows.length : 0
|
|
||||||
const analysisComplete = Array.isArray(cows)
|
|
||||||
? cows.filter((cow: any) => cow.genomeScore !== null && cow.genomeScore !== undefined).length
|
|
||||||
: 0
|
|
||||||
const avgGenomeScore = evaluationData?.genomeScore ?? 0
|
|
||||||
const breedSaveCount = Array.isArray(breedSaves) ? breedSaves.length : 0
|
|
||||||
|
|
||||||
// KPI 데이터 구성
|
|
||||||
setKpiData([
|
|
||||||
{
|
|
||||||
label: "전체 개체 수",
|
|
||||||
value: totalCows,
|
|
||||||
change: 0,
|
|
||||||
changeLabel: `분석 완료: ${analysisComplete}마리`,
|
|
||||||
icon: <Users className="h-7 w-7" />,
|
|
||||||
color: "blue",
|
|
||||||
subtext: `분석 완료 ${analysisComplete}마리`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "유전체 평균",
|
|
||||||
value: avgGenomeScore.toFixed(1),
|
|
||||||
change: avgGenomeScore >= 70 ? 4.8 : -1.5,
|
|
||||||
changeLabel: avgGenomeScore >= 70 ? '육질/육량 형질 우수' : '형질 개선 필요',
|
|
||||||
icon: <Activity className="h-7 w-7" />,
|
|
||||||
color: "cyan",
|
|
||||||
badge: avgGenomeScore >= 70 ? (
|
|
||||||
<Badge className="badge-gene-positive flex items-center gap-1">
|
|
||||||
<IconTrendingUp className="w-3 h-3" /> 우수
|
|
||||||
</Badge>
|
|
||||||
) : avgGenomeScore >= 50 ? (
|
|
||||||
<Badge className="badge-gene-neutral flex items-center gap-1">
|
|
||||||
<IconMoodNeutral className="w-3 h-3" /> 보통
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge className="badge-gene-negative flex items-center gap-1">
|
|
||||||
<IconTrendingDown className="w-3 h-3" /> 개선필요
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "교배계획 저장",
|
|
||||||
value: breedSaveCount,
|
|
||||||
change: 0,
|
|
||||||
changeLabel: '저장된 교배 조합',
|
|
||||||
icon: <Heart className="h-7 w-7" />,
|
|
||||||
color: "pink"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
} catch (error) {
|
|
||||||
console.error('KPI 데이터 로드 실패:', error)
|
|
||||||
console.error('에러 상세:', error instanceof Error ? error.message : '알 수 없는 에러')
|
|
||||||
// 에러 시 기본값
|
|
||||||
setKpiData([
|
|
||||||
{
|
|
||||||
label: "전체 개체 수",
|
|
||||||
value: 0,
|
|
||||||
change: 0,
|
|
||||||
changeLabel: "데이터 없음",
|
|
||||||
icon: <Users className="h-7 w-7" />,
|
|
||||||
color: "blue"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "유전체 평균",
|
|
||||||
value: "0.0",
|
|
||||||
change: 0,
|
|
||||||
changeLabel: "데이터 없음",
|
|
||||||
icon: <Activity className="h-7 w-7" />,
|
|
||||||
color: "cyan"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "교배계획 저장",
|
|
||||||
value: 0,
|
|
||||||
change: 0,
|
|
||||||
changeLabel: "데이터 없음",
|
|
||||||
icon: <Heart className="h-7 w-7" />,
|
|
||||||
color: "pink"
|
|
||||||
}
|
|
||||||
])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchData()
|
|
||||||
}, [farmNo, user, filters.selectedGenes])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Card key={i} className="bg-slate-50/50 border-0">
|
|
||||||
<CardContent className="p-4 md:p-5">
|
|
||||||
<div className="animate-pulse space-y-3">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="h-4 w-24 bg-gray-200 rounded" />
|
|
||||||
<div className="h-5 w-12 bg-gray-200 rounded-full" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-10 w-16 bg-gray-300 rounded" />
|
|
||||||
<div className="h-3 w-32 bg-gray-200 rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getColorClasses = (color: string) => {
|
|
||||||
const colors = {
|
|
||||||
blue: "bg-blue-50 text-blue-700 border-blue-200",
|
|
||||||
cyan: "bg-cyan-50 text-cyan-700 border-cyan-200",
|
|
||||||
pink: "bg-pink-50 text-pink-700 border-pink-200",
|
|
||||||
orange: "bg-orange-50 text-orange-700 border-orange-200"
|
|
||||||
}
|
|
||||||
return colors[color as keyof typeof colors] || colors.blue
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTrendIcon = (change: number) => {
|
|
||||||
if (change > 0) return <TrendingUp className="h-4 w-4" />
|
|
||||||
if (change < 0) return <TrendingDown className="h-4 w-4" />
|
|
||||||
return <Minus className="h-4 w-4" />
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTrendColor = (change: number) => {
|
|
||||||
if (change > 0) return "text-green-600 bg-green-50"
|
|
||||||
if (change < 0) return "text-red-600 bg-red-50"
|
|
||||||
return "text-gray-600 bg-gray-50"
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{kpiData.map((kpi, index) => (
|
|
||||||
<Card key={index} className="bg-slate-50/50 border-0 hover:bg-slate-100/50 transition-colors">
|
|
||||||
<CardContent className="p-4 md:p-5">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<p className="text-sm md:text-sm text-gray-600 font-medium">{kpi.label}</p>
|
|
||||||
{kpi.badge ? (
|
|
||||||
<div>{kpi.badge}</div>
|
|
||||||
) : kpi.change !== 0 ? (
|
|
||||||
<div className={`flex items-center gap-1 text-xs font-medium ${
|
|
||||||
kpi.change > 0 ? 'text-green-600' :
|
|
||||||
kpi.change < 0 ? 'text-red-600' :
|
|
||||||
'text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{getTrendIcon(kpi.change)}
|
|
||||||
<span>{Math.abs(kpi.change)}%</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<p className="text-3xl md:text-4xl font-bold tracking-tight">
|
|
||||||
{kpi.value}
|
|
||||||
</p>
|
|
||||||
{index === 0 && <span className="text-sm text-gray-500">두</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{kpi.changeLabel && (
|
|
||||||
<p className="text-xs md:text-sm text-gray-500 line-clamp-1">{kpi.changeLabel}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { IconTrendingDown, IconTrendingUp, IconMoodNeutral } from "@tabler/icons-react"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardContent,
|
|
||||||
} from "@/components/ui/card"
|
|
||||||
import { dashboardApi } from "@/lib/api"
|
|
||||||
import { FarmSummaryDto, FarmEvaluationDto } from "@/types/dashboard.types"
|
|
||||||
|
|
||||||
interface SectionCardsProps {
|
|
||||||
farmNo: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SectionCards({ farmNo }: SectionCardsProps) {
|
|
||||||
const [summary, setSummary] = useState<FarmSummaryDto | null>(null)
|
|
||||||
const [evaluation, setEvaluation] = useState<FarmEvaluationDto | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
if (!farmNo) {
|
|
||||||
console.warn('farmNo가 없습니다.')
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 백엔드 API에서 실제 데이터 조회
|
|
||||||
const [summaryData, evaluationData] = await Promise.all([
|
|
||||||
dashboardApi.getFarmSummary(farmNo).catch(err => {
|
|
||||||
console.error('농장 요약 조회 실패:', err)
|
|
||||||
return null
|
|
||||||
}),
|
|
||||||
dashboardApi.getFarmEvaluation(farmNo).catch(err => {
|
|
||||||
console.error('농장 평가 조회 실패:', err)
|
|
||||||
return null
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
setSummary(summaryData)
|
|
||||||
setEvaluation(evaluationData)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('대시보드 데이터 로드 실패:', error)
|
|
||||||
|
|
||||||
// API 실패 시 기본값 설정
|
|
||||||
setSummary({
|
|
||||||
totalCows: 0,
|
|
||||||
analysisComplete: 0,
|
|
||||||
})
|
|
||||||
setEvaluation(null)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchData()
|
|
||||||
}, [farmNo])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
|
||||||
{[1, 2, 3, 4].map((i) => (
|
|
||||||
<Card key={i} className="@container/card animate-pulse">
|
|
||||||
<CardHeader>
|
|
||||||
<CardDescription>로딩 중...</CardDescription>
|
|
||||||
<CardTitle className="text-2xl font-semibold">--</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCows = summary?.totalCows || 0
|
|
||||||
const analysisComplete = summary?.analysisComplete || 0
|
|
||||||
// 유전자 보유율 = (육질형 + 육량형) / 2
|
|
||||||
const avgGeneScore = evaluation?.genePossession
|
|
||||||
? (evaluation.genePossession.meatQuality.averageRate + evaluation.genePossession.meatQuantity.averageRate) / 2
|
|
||||||
: 0
|
|
||||||
const avgGenomeScore = evaluation?.genomeScore || 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 gap-5 px-4 lg:px-6 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<Card className="shadow-sm hover:shadow-md transition-all duration-200 border-l-4 border-l-blue-500">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardDescription className="text-xs font-semibold uppercase tracking-wide">전체 개체 수</CardDescription>
|
|
||||||
<CardTitle className="text-3xl font-bold text-foreground mt-2">{totalCows}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
분석 완료 <span className="font-semibold text-foreground">{analysisComplete}마리</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="shadow-sm hover:shadow-md transition-all duration-200 border-l-4 border-l-amber-500">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardDescription className="text-xs font-semibold uppercase tracking-wide">유전자 보유율</CardDescription>
|
|
||||||
<CardTitle className="text-3xl font-bold text-foreground mt-2">{avgGeneScore.toFixed(1)}%</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Badge className={`${avgGeneScore >= 70 ? 'badge-gene-positive' : avgGeneScore >= 50 ? 'badge-gene-neutral' : 'badge-gene-negative'} flex items-center gap-1 w-fit`}>
|
|
||||||
{avgGeneScore >= 70 ? <IconTrendingUp className="w-3 h-3" /> : avgGeneScore >= 50 ? <IconMoodNeutral className="w-3 h-3" /> : <IconTrendingDown className="w-3 h-3" />}
|
|
||||||
{avgGeneScore >= 70 ? '우수' : avgGeneScore >= 50 ? '보통' : '개선필요'}
|
|
||||||
</Badge>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
{avgGeneScore >= 70 ? '우량 유전자 보유율 높음' : '유전자 개선 필요'}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="shadow-sm hover:shadow-md transition-all duration-200 border-l-4 border-l-cyan-500">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardDescription className="text-xs font-semibold uppercase tracking-wide">유전체 평균 점수</CardDescription>
|
|
||||||
<CardTitle className="text-3xl font-bold text-foreground mt-2">{avgGenomeScore.toFixed(1)}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Badge className={`${avgGenomeScore >= 70 ? 'badge-gene-positive' : avgGenomeScore >= 50 ? 'badge-gene-neutral' : 'badge-gene-negative'} flex items-center gap-1 w-fit`}>
|
|
||||||
{avgGenomeScore >= 70 ? <IconTrendingUp className="w-3 h-3" /> : avgGenomeScore >= 50 ? <IconMoodNeutral className="w-3 h-3" /> : <IconTrendingDown className="w-3 h-3" />}
|
|
||||||
{avgGenomeScore >= 70 ? '우수' : avgGenomeScore >= 50 ? '보통' : '개선필요'}
|
|
||||||
</Badge>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
{avgGenomeScore >= 70 ? '육질/육량 형질 우수' : '형질 개선 필요'}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="@container/card">
|
|
||||||
<CardHeader>
|
|
||||||
<CardDescription>유전체 점수</CardDescription>
|
|
||||||
<CardTitle className={`text-2xl font-semibold ${
|
|
||||||
(evaluation?.genomeScore || 0) >= 70 ? 'text-green-600' :
|
|
||||||
(evaluation?.genomeScore || 0) >= 60 ? 'text-blue-600' :
|
|
||||||
(evaluation?.genomeScore || 0) >= 40 ? 'text-yellow-600' :
|
|
||||||
'text-red-600'
|
|
||||||
}`}>
|
|
||||||
{evaluation?.genomeScore?.toFixed(1) || '-'}점
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Badge className={
|
|
||||||
(evaluation?.genomeScore || 0) >= 70 ? 'bg-green-100 text-green-800' :
|
|
||||||
(evaluation?.genomeScore || 0) >= 60 ? 'bg-blue-100 text-blue-800' :
|
|
||||||
(evaluation?.genomeScore || 0) >= 40 ? 'bg-yellow-100 text-yellow-800' :
|
|
||||||
'bg-red-100 text-red-800'
|
|
||||||
}>
|
|
||||||
{(evaluation?.genomeScore || 0) >= 60 ? <IconTrendingUp className="w-4 h-4" /> : <IconMoodNeutral className="w-4 h-4" />}
|
|
||||||
{evaluation?.genomeRank || '-'}위
|
|
||||||
</Badge>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
보은군 내 순위 (유전체 기준)
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
|
|
||||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
|
||||||
import { Trophy, AlertTriangle, Sparkles, Star, AlertCircle, Lightbulb } from "lucide-react"
|
|
||||||
import { dashboardApi } from "@/lib/api/dashboard.api"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
interface CattleItem {
|
|
||||||
rank: number
|
|
||||||
cattleId: string
|
|
||||||
name: string
|
|
||||||
score: number
|
|
||||||
reason: string
|
|
||||||
scoreUnit?: string // 점수 단위 (점, 마리 등)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Top3ListsProps {
|
|
||||||
farmNo: number | null
|
|
||||||
mode?: 'full' | 'compact' | 'cull-only' | 'recommend-only'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Top3Lists({ farmNo, mode = 'full' }: Top3ListsProps) {
|
|
||||||
const { selectedYear } = useAnalysisYear()
|
|
||||||
const { filters } = useGlobalFilter()
|
|
||||||
const [excellentList, setExcellentList] = useState<CattleItem[]>([])
|
|
||||||
const [cullingList, setCullingList] = useState<CattleItem[]>([])
|
|
||||||
const [recommendList, setRecommendList] = useState<CattleItem[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
if (!farmNo) {
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 필터 조건 생성
|
|
||||||
const filterDto = {
|
|
||||||
targetGenes: filters.selectedGenes,
|
|
||||||
limit: 3, // Top 3만 가져오기
|
|
||||||
}
|
|
||||||
|
|
||||||
// 병렬로 API 호출
|
|
||||||
const [excellentData, cullData, kpnData] = await Promise.all([
|
|
||||||
dashboardApi.getExcellentCows(farmNo, filterDto),
|
|
||||||
dashboardApi.getCullCows(farmNo, filterDto),
|
|
||||||
dashboardApi.getKpnRecommendationAggregation(farmNo, filterDto),
|
|
||||||
])
|
|
||||||
|
|
||||||
// 우수개체 데이터 변환
|
|
||||||
const excellentList: CattleItem[] = (excellentData || []).map((item: any, index: number) => ({
|
|
||||||
rank: index + 1,
|
|
||||||
cattleId: item.cowNo || '없음',
|
|
||||||
name: item.cowName || item.cowNo || '정보 없음', // 이름이 없으면 개체번호 사용
|
|
||||||
score: Math.round(item.overallScore || 0),
|
|
||||||
scoreUnit: '점',
|
|
||||||
reason: item.reason || '정보 없음',
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 도태개체 데이터 변환
|
|
||||||
const cullList: CattleItem[] = (cullData || []).map((item: any, index: number) => ({
|
|
||||||
rank: index + 1,
|
|
||||||
cattleId: item.cowNo || '없음',
|
|
||||||
name: item.cowName || item.cowNo || '정보 없음', // 이름이 없으면 개체번호 사용
|
|
||||||
score: Math.round(item.overallScore || 0),
|
|
||||||
scoreUnit: '점',
|
|
||||||
reason: item.reason || '정보 없음',
|
|
||||||
}))
|
|
||||||
|
|
||||||
// KPN 추천 데이터 변환 (상위 3개)
|
|
||||||
const recommendList: CattleItem[] = ((kpnData?.kpnAggregations || []) as any[])
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((item: any, index: number) => ({
|
|
||||||
rank: index + 1,
|
|
||||||
cattleId: item.kpnNumber || '없음',
|
|
||||||
name: item.kpnName || '이름 없음',
|
|
||||||
score: item.recommendedCowCount || 0,
|
|
||||||
scoreUnit: '마리',
|
|
||||||
reason: `평균 매칭점수 ${Math.round(item.avgMatchingScore || 0)}점`,
|
|
||||||
}))
|
|
||||||
|
|
||||||
setExcellentList(excellentList)
|
|
||||||
setCullingList(cullList)
|
|
||||||
setRecommendList(recommendList)
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(`데이터를 불러오는데 실패했습니다: ${error?.message || '알 수 없는 오류'}`)
|
|
||||||
|
|
||||||
// 에러 시 빈 배열 설정
|
|
||||||
setExcellentList([])
|
|
||||||
setCullingList([])
|
|
||||||
setRecommendList([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchData()
|
|
||||||
}, [selectedYear, filters, farmNo])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 md:gap-4">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Card key={i} className="border-gray-200">
|
|
||||||
<CardHeader className="pb-2 md:pb-3">
|
|
||||||
<CardTitle className="text-xs md:text-sm font-semibold">로딩 중...</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0 pb-2 md:pb-3">
|
|
||||||
<div className="h-[150px] flex items-center justify-center">
|
|
||||||
<div className="animate-pulse space-y-2 w-full">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!farmNo) {
|
|
||||||
return (
|
|
||||||
<div className="px-4 lg:px-6">
|
|
||||||
<h2 className="text-xl font-bold mb-4">주요 개체 및 추천</h2>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Card key={i}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>
|
|
||||||
{i === 1 ? '우수개체 Top3' : i === 2 ? '도태대상 Top3' : 'KPN추천 Top3'}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="h-[200px] flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-muted-foreground mb-2">농장 정보가 없습니다</p>
|
|
||||||
<p className="text-sm text-muted-foreground">로그인 후 다시 시도해주세요</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderListCard = (
|
|
||||||
title: string,
|
|
||||||
description: string,
|
|
||||||
icon: React.ReactNode,
|
|
||||||
items: CattleItem[],
|
|
||||||
variant: 'excellent' | 'culling' | 'recommend'
|
|
||||||
) => {
|
|
||||||
const colorSchemes = {
|
|
||||||
excellent: {
|
|
||||||
badge: 'bg-green-600 text-white'
|
|
||||||
},
|
|
||||||
culling: {
|
|
||||||
badge: 'bg-red-600 text-white'
|
|
||||||
},
|
|
||||||
recommend: {
|
|
||||||
badge: 'bg-blue-600 text-white'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheme = colorSchemes[variant]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-4">
|
|
||||||
<div className="space-y-2.5">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={item.cattleId}
|
|
||||||
className="relative p-3 rounded-lg border border-gray-200 bg-white hover:shadow-sm transition-shadow"
|
|
||||||
>
|
|
||||||
{/* 순위 배지 */}
|
|
||||||
<div className="absolute -top-1.5 -right-1.5">
|
|
||||||
<div className={`w-6 h-6 rounded-full ${scheme.badge} flex items-center justify-center text-[10px] font-bold shadow-sm`}>
|
|
||||||
{item.rank}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-semibold text-sm text-foreground mb-0.5">{item.name}</p>
|
|
||||||
<p className="text-[10px] text-muted-foreground font-mono">{item.cattleId}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right ml-2">
|
|
||||||
<p className="text-xl font-bold text-foreground">{item.score}</p>
|
|
||||||
<p className="text-[10px] text-muted-foreground">{item.scoreUnit || '점'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-2 border-t border-gray-100">
|
|
||||||
<p className="text-xs text-muted-foreground">{item.reason}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 요약 */}
|
|
||||||
<div className="mt-4 pt-3 border-t border-border">
|
|
||||||
<div className="text-center space-y-0.5">
|
|
||||||
<p className="text-xs font-medium text-foreground">
|
|
||||||
{variant === 'excellent'
|
|
||||||
? '농장 내 최상위 개체'
|
|
||||||
: variant === 'culling'
|
|
||||||
? '개선 또는 도태 권장'
|
|
||||||
: '최적 교배 추천'}
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
{filters.analysisIndex === 'GENE' ? '유전자 기반 분석' : '유전능력 기반 분석'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// compact 모드: 우수개체만 표시
|
|
||||||
if (mode === 'compact') {
|
|
||||||
return renderListCard(
|
|
||||||
'우수개체 Top3',
|
|
||||||
'농장 내 상위 개체',
|
|
||||||
<Trophy className="h-5 w-5 text-green-600" />,
|
|
||||||
excellentList,
|
|
||||||
'excellent'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// cull-only 모드: 도태대상만 표시
|
|
||||||
if (mode === 'cull-only') {
|
|
||||||
return renderListCard(
|
|
||||||
'도태대상 Top3',
|
|
||||||
'개선 필요 개체',
|
|
||||||
<AlertTriangle className="h-5 w-5 text-red-600" />,
|
|
||||||
cullingList,
|
|
||||||
'culling'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// recommend-only 모드: KPN추천만 표시
|
|
||||||
if (mode === 'recommend-only') {
|
|
||||||
return renderListCard(
|
|
||||||
'KPN추천 Top3',
|
|
||||||
'최적 씨수소 추천',
|
|
||||||
<Sparkles className="h-5 w-5 text-blue-600" />,
|
|
||||||
recommendList,
|
|
||||||
'recommend'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vercel 스타일 리스트 아이템 렌더링
|
|
||||||
const renderListItem = (item: CattleItem, variant: 'excellent' | 'culling' | 'recommend') => {
|
|
||||||
const colorSchemes = {
|
|
||||||
excellent: {
|
|
||||||
icon: <Trophy className="h-4 w-4 text-green-600" />,
|
|
||||||
scoreColor: 'text-green-600',
|
|
||||||
dotColor: 'bg-green-500'
|
|
||||||
},
|
|
||||||
culling: {
|
|
||||||
icon: <AlertTriangle className="h-4 w-4 text-red-600" />,
|
|
||||||
scoreColor: 'text-red-600',
|
|
||||||
dotColor: 'bg-red-500'
|
|
||||||
},
|
|
||||||
recommend: {
|
|
||||||
icon: <Sparkles className="h-4 w-4 text-blue-600" />,
|
|
||||||
scoreColor: 'text-blue-600',
|
|
||||||
dotColor: 'bg-blue-500'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheme = colorSchemes[variant]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between py-2 md:py-2.5 border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors px-1 md:px-1.5 -mx-1 md:-mx-1.5 rounded">
|
|
||||||
<div className="flex items-center gap-2 md:gap-2.5 flex-1 min-w-0">
|
|
||||||
<div className={`w-1 h-1 md:w-1.5 md:h-1.5 rounded-full ${scheme.dotColor} flex-shrink-0`}></div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-1 md:gap-1.5">
|
|
||||||
<p className="text-xs md:text-sm font-medium text-foreground truncate">{item.name}</p>
|
|
||||||
<span className="text-[9px] md:text-[10px] text-gray-400 font-mono flex-shrink-0">{item.cattleId}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[9px] md:text-[10px] text-gray-500 mt-0.5 line-clamp-1">{item.reason}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 md:gap-1.5 flex-shrink-0 ml-2">
|
|
||||||
<p className={`text-sm md:text-base font-semibold ${scheme.scoreColor}`}>{item.score}</p>
|
|
||||||
<span className="text-[9px] md:text-[10px] text-gray-400">{item.scoreUnit || '점'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// full 모드: Vercel 스타일 리스트로 표시
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 md:gap-4">
|
|
||||||
{/* 우수개체 섹션 */}
|
|
||||||
<Card className="border-gray-200 shadow-none hover:border-black hover:shadow-lg transition-all duration-200">
|
|
||||||
<CardHeader className="pb-2 md:pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-1.5 md:gap-2">
|
|
||||||
<Trophy className="h-3 w-3 md:h-3.5 md:w-3.5 text-green-600" />
|
|
||||||
<CardTitle className="text-xs md:text-sm font-semibold">우수개체 Top3</CardTitle>
|
|
||||||
</div>
|
|
||||||
<svg className="w-3 h-3 md:w-3.5 md:h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0 pb-2 md:pb-3">
|
|
||||||
{excellentList.length > 0 ? (
|
|
||||||
<div className="space-y-0">
|
|
||||||
{excellentList.map((item) => (
|
|
||||||
<div key={item.cattleId}>
|
|
||||||
{renderListItem(item, 'excellent')}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-[150px] flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<Trophy className="h-8 w-8 text-gray-300 mx-auto mb-2" />
|
|
||||||
<p className="text-xs text-muted-foreground">데이터가 없습니다</p>
|
|
||||||
<p className="text-[10px] text-gray-400 mt-1">유전체 분석 데이터를 등록해주세요</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 도태대상 섹션 */}
|
|
||||||
<Card className="border-gray-200 shadow-none hover:border-black hover:shadow-lg transition-all duration-200">
|
|
||||||
<CardHeader className="pb-2 md:pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-1.5 md:gap-2">
|
|
||||||
<AlertTriangle className="h-3 w-3 md:h-3.5 md:w-3.5 text-red-600" />
|
|
||||||
<CardTitle className="text-xs md:text-sm font-semibold">도태대상 Top3</CardTitle>
|
|
||||||
</div>
|
|
||||||
<svg className="w-3 h-3 md:w-3.5 md:h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0 pb-2 md:pb-3">
|
|
||||||
{cullingList.length > 0 ? (
|
|
||||||
<div className="space-y-0">
|
|
||||||
{cullingList.map((item) => (
|
|
||||||
<div key={item.cattleId}>
|
|
||||||
{renderListItem(item, 'culling')}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-[150px] flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<AlertTriangle className="h-8 w-8 text-gray-300 mx-auto mb-2" />
|
|
||||||
<p className="text-xs text-muted-foreground">데이터가 없습니다</p>
|
|
||||||
<p className="text-[10px] text-gray-400 mt-1">유전체 분석 데이터를 등록해주세요</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* KPN 추천 섹션 */}
|
|
||||||
<Card className="border-gray-200 shadow-none hover:border-black hover:shadow-lg transition-all duration-200">
|
|
||||||
<CardHeader className="pb-2 md:pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-1.5 md:gap-2">
|
|
||||||
<Sparkles className="h-3 w-3 md:h-3.5 md:w-3.5 text-blue-600" />
|
|
||||||
<CardTitle className="text-xs md:text-sm font-semibold">KPN 추천 Top3</CardTitle>
|
|
||||||
</div>
|
|
||||||
<svg className="w-3 h-3 md:w-3.5 md:h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0 pb-2 md:pb-3">
|
|
||||||
{recommendList.length > 0 ? (
|
|
||||||
<div className="space-y-0">
|
|
||||||
{recommendList.map((item) => (
|
|
||||||
<div key={item.cattleId}>
|
|
||||||
{renderListItem(item, 'recommend')}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-[150px] flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<Sparkles className="h-8 w-8 text-gray-300 mx-auto mb-2" />
|
|
||||||
<p className="text-xs text-muted-foreground">데이터가 없습니다</p>
|
|
||||||
<p className="text-[10px] text-gray-400 mt-1">KPN 및 개체 데이터를 등록해주세요</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
|
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
|
||||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
import { useFilterStore } from "@/store/filter-store"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Filter, ChevronDown, ChevronUp } from "lucide-react"
|
import { Filter, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
@@ -21,7 +21,7 @@ interface GenePossessionStatusProps {
|
|||||||
|
|
||||||
export function GenePossessionStatus({ farmNo }: GenePossessionStatusProps) {
|
export function GenePossessionStatus({ farmNo }: GenePossessionStatusProps) {
|
||||||
const { selectedYear } = useAnalysisYear()
|
const { selectedYear } = useAnalysisYear()
|
||||||
const { filters } = useGlobalFilter()
|
const { filters } = useFilterStore()
|
||||||
const [allGenes, setAllGenes] = useState<GeneData[]>([])
|
const [allGenes, setAllGenes] = useState<GeneData[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { Search, X, Filter, Sparkles } from "lucide-react"
|
import { Search, X, Filter, Sparkles } from "lucide-react"
|
||||||
import { geneApi, type MarkerModel } from "@/lib/api/gene.api"
|
import { geneApi } from "@/lib/api/gene.api"
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
|
||||||
interface GeneSearchDrawerProps {
|
interface GeneSearchDrawerProps {
|
||||||
@@ -18,7 +18,7 @@ interface GeneSearchDrawerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GeneSearchModal({ open, onOpenChange, selectedGenes, onGenesChange }: GeneSearchDrawerProps) {
|
export function GeneSearchModal({ open, onOpenChange, selectedGenes, onGenesChange }: GeneSearchDrawerProps) {
|
||||||
const [allGenes, setAllGenes] = useState<MarkerModel[]>([])
|
const [allGenes, setAllGenes] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const [filterType, setFilterType] = useState<'ALL' | 'QTY' | 'QLT'>('ALL')
|
const [filterType, setFilterType] = useState<'ALL' | 'QTY' | 'QLT'>('ALL')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { PieChart as PieChartIcon } from "lucide-react"
|
import { PieChart as PieChartIcon } from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { apiClient } from "@/lib/api"
|
import apiClient from "@/lib/api-client"
|
||||||
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts'
|
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts'
|
||||||
|
|
||||||
interface DistributionData {
|
interface DistributionData {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Target } from "lucide-react"
|
import { Target } from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { apiClient } from "@/lib/api"
|
import apiClient from "@/lib/api-client"
|
||||||
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Tooltip } from 'recharts'
|
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Tooltip } from 'recharts'
|
||||||
|
|
||||||
interface TraitScore {
|
interface TraitScore {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { ArrowUpRight, ArrowDownRight, TrendingUp, TrendingDown } from "lucide-react"
|
import { ArrowUpRight, ArrowDownRight, TrendingUp, TrendingDown } from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { apiClient } from "@/lib/api"
|
import apiClient from "@/lib/api-client"
|
||||||
|
|
||||||
interface GenomeData {
|
interface GenomeData {
|
||||||
trait: string
|
trait: string
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { BarChart3 } from "lucide-react"
|
import { BarChart3 } from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { apiClient } from "@/lib/api"
|
import apiClient from "@/lib/api-client"
|
||||||
|
|
||||||
interface TraitData {
|
interface TraitData {
|
||||||
trait: string
|
trait: string
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { useAnalysisYear } from "@/contexts/AnalysisYearContext";
|
import { useAnalysisYear } from "@/contexts/AnalysisYearContext";
|
||||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext";
|
import { useFilterStore } from "@/store/filter-store";
|
||||||
import { useAuthStore } from "@/store/auth-store";
|
import { useAuthStore } from "@/store/auth-store";
|
||||||
import { LogOut, User } from "lucide-react";
|
import { LogOut, User } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -28,7 +28,7 @@ import { useRouter } from "next/navigation";
|
|||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { filters } = useGlobalFilter();
|
const { filters } = useFilterStore();
|
||||||
const { selectedYear, setSelectedYear, availableYears } = useAnalysisYear();
|
const { selectedYear, setSelectedYear, availableYears } = useAnalysisYear();
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
|
|||||||
180
frontend/src/constants/traits.ts
Normal file
180
frontend/src/constants/traits.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* 형질(Trait) 관련 상수 정의
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 유전체 분석에서 사용하는 35개 형질 목록
|
||||||
|
* 백엔드 TraitTypes.ts와 동기화 필요
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 성장형질 (1개) */
|
||||||
|
export const GROWTH_TRAITS = ['12개월령체중'] as const;
|
||||||
|
|
||||||
|
/** 경제형질 (4개) - 생산 카테고리 */
|
||||||
|
export const ECONOMIC_TRAITS = ['도체중', '등심단면적', '등지방두께', '근내지방도'] as const;
|
||||||
|
|
||||||
|
/** 체형형질 (10개) */
|
||||||
|
export const BODY_TRAITS = [
|
||||||
|
'체고', '십자', '체장', '흉심', '흉폭',
|
||||||
|
'고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 부위별 무게 (10개) */
|
||||||
|
export const WEIGHT_TRAITS = [
|
||||||
|
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
||||||
|
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 부위별 비율 (10개) */
|
||||||
|
export const RATE_TRAITS = [
|
||||||
|
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
||||||
|
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 전체 형질 (35개) */
|
||||||
|
export const ALL_TRAITS = [
|
||||||
|
...GROWTH_TRAITS,
|
||||||
|
...ECONOMIC_TRAITS,
|
||||||
|
...BODY_TRAITS,
|
||||||
|
...WEIGHT_TRAITS,
|
||||||
|
...RATE_TRAITS,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 낮을수록 좋은 형질 (부호 반전 필요) */
|
||||||
|
export const NEGATIVE_TRAITS: string[] = ['등지방두께'];
|
||||||
|
|
||||||
|
/** 기본 선택 형질 (7개) */
|
||||||
|
export const DEFAULT_TRAITS = ['도체중', '등심단면적', '등지방두께', '근내지방도', '체장', '체고', '등심weight'] as const;
|
||||||
|
|
||||||
|
/** 형질 타입 */
|
||||||
|
export type TraitName = typeof ALL_TRAITS[number];
|
||||||
|
|
||||||
|
/** 카테고리 타입 */
|
||||||
|
export type TraitCategory = '성장' | '생산' | '체형' | '무게' | '비율';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리별 형질 목록
|
||||||
|
*/
|
||||||
|
export const TRAIT_CATEGORIES: Record<TraitCategory, readonly string[]> = {
|
||||||
|
'성장': GROWTH_TRAITS,
|
||||||
|
'생산': ECONOMIC_TRAITS,
|
||||||
|
'체형': BODY_TRAITS,
|
||||||
|
'무게': WEIGHT_TRAITS,
|
||||||
|
'비율': RATE_TRAITS,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI용 카테고리 정보 (id, name, traits)
|
||||||
|
*/
|
||||||
|
export const TRAIT_CATEGORY_LIST = [
|
||||||
|
{ id: 'growth', name: '성장형질', traits: [...GROWTH_TRAITS] },
|
||||||
|
{ id: 'economic', name: '경제형질', traits: [...ECONOMIC_TRAITS] },
|
||||||
|
{ id: 'body', name: '체형형질', traits: [...BODY_TRAITS] },
|
||||||
|
{ id: 'weight', name: '부위별무게', traits: [...WEIGHT_TRAITS] },
|
||||||
|
{ id: 'rate', name: '부위별비율', traits: [...RATE_TRAITS] },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 형질별 카테고리 매핑
|
||||||
|
*/
|
||||||
|
export const TRAIT_CATEGORY_MAP: Record<string, TraitCategory> = {
|
||||||
|
// 성장
|
||||||
|
'12개월령체중': '성장',
|
||||||
|
// 생산
|
||||||
|
'도체중': '생산',
|
||||||
|
'등심단면적': '생산',
|
||||||
|
'등지방두께': '생산',
|
||||||
|
'근내지방도': '생산',
|
||||||
|
// 체형
|
||||||
|
'체고': '체형',
|
||||||
|
'십자': '체형',
|
||||||
|
'체장': '체형',
|
||||||
|
'흉심': '체형',
|
||||||
|
'흉폭': '체형',
|
||||||
|
'고장': '체형',
|
||||||
|
'요각폭': '체형',
|
||||||
|
'좌골폭': '체형',
|
||||||
|
'곤폭': '체형',
|
||||||
|
'흉위': '체형',
|
||||||
|
// 무게
|
||||||
|
'안심weight': '무게',
|
||||||
|
'등심weight': '무게',
|
||||||
|
'채끝weight': '무게',
|
||||||
|
'목심weight': '무게',
|
||||||
|
'앞다리weight': '무게',
|
||||||
|
'우둔weight': '무게',
|
||||||
|
'설도weight': '무게',
|
||||||
|
'사태weight': '무게',
|
||||||
|
'양지weight': '무게',
|
||||||
|
'갈비weight': '무게',
|
||||||
|
// 비율
|
||||||
|
'안심rate': '비율',
|
||||||
|
'등심rate': '비율',
|
||||||
|
'채끝rate': '비율',
|
||||||
|
'목심rate': '비율',
|
||||||
|
'앞다리rate': '비율',
|
||||||
|
'우둔rate': '비율',
|
||||||
|
'설도rate': '비율',
|
||||||
|
'사태rate': '비율',
|
||||||
|
'양지rate': '비율',
|
||||||
|
'갈비rate': '비율',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 형질 설명 (툴팁용)
|
||||||
|
*/
|
||||||
|
export const TRAIT_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
// 성장형질
|
||||||
|
'12개월령체중': '12개월 시점 체중',
|
||||||
|
// 경제형질
|
||||||
|
'도체중': '도축 후 고기 무게',
|
||||||
|
'등심단면적': '등심의 단면 크기',
|
||||||
|
'등지방두께': '등 부위 지방 두께 (낮을수록 좋음)',
|
||||||
|
'근내지방도': '마블링 정도 (높을수록 고급육)',
|
||||||
|
// 체형형질
|
||||||
|
'체고': '어깨 높이',
|
||||||
|
'십자': '십자부(엉덩이) 높이',
|
||||||
|
'체장': '몸통 길이',
|
||||||
|
'흉심': '가슴 깊이',
|
||||||
|
'흉폭': '가슴 너비',
|
||||||
|
'고장': '엉덩이 길이',
|
||||||
|
'요각폭': '허리뼈 너비',
|
||||||
|
'좌골폭': '좌골 너비',
|
||||||
|
'곤폭': '좌골단 너비',
|
||||||
|
'흉위': '가슴둘레',
|
||||||
|
// 부위별 무게
|
||||||
|
'안심weight': '안심 부위 무게',
|
||||||
|
'등심weight': '등심 부위 무게',
|
||||||
|
'채끝weight': '채끝 부위 무게',
|
||||||
|
'목심weight': '목심 부위 무게',
|
||||||
|
'앞다리weight': '앞다리 부위 무게',
|
||||||
|
'우둔weight': '우둔 부위 무게',
|
||||||
|
'설도weight': '설도 부위 무게',
|
||||||
|
'사태weight': '사태 부위 무게',
|
||||||
|
'양지weight': '양지 부위 무게',
|
||||||
|
'갈비weight': '갈비 부위 무게',
|
||||||
|
// 부위별 비율
|
||||||
|
'안심rate': '전체 대비 안심 비율',
|
||||||
|
'등심rate': '전체 대비 등심 비율',
|
||||||
|
'채끝rate': '전체 대비 채끝 비율',
|
||||||
|
'목심rate': '전체 대비 목심 비율',
|
||||||
|
'앞다리rate': '전체 대비 앞다리 비율',
|
||||||
|
'우둔rate': '전체 대비 우둔 비율',
|
||||||
|
'설도rate': '전체 대비 설도 비율',
|
||||||
|
'사태rate': '전체 대비 사태 비율',
|
||||||
|
'양지rate': '전체 대비 양지 비율',
|
||||||
|
'갈비rate': '전체 대비 갈비 비율',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 형질명으로 카테고리 조회
|
||||||
|
*/
|
||||||
|
export function getTraitCategory(traitName: string): TraitCategory | '기타' {
|
||||||
|
return TRAIT_CATEGORY_MAP[traitName] ?? '기타';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 형질명으로 설명 조회
|
||||||
|
*/
|
||||||
|
export function getTraitDescription(traitName: string): string {
|
||||||
|
return TRAIT_DESCRIPTIONS[traitName] ?? traitName;
|
||||||
|
}
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AnalysisYearContext - 분석 연도 선택 Context
|
||||||
|
*
|
||||||
|
* 기능:
|
||||||
|
* - 현재 연도부터 5년 전까지 선택 가능 (예: 2025~2020)
|
||||||
|
* - URL 파라미터 ?year=2024 와 동기화
|
||||||
|
*
|
||||||
|
* 사용처:
|
||||||
|
* - site-header.tsx: 헤더 연도 선택 드롭다운
|
||||||
|
* - genome-integrated-comparison.tsx: 선택된 연도로 데이터 조회
|
||||||
|
* - gene-possession-status.tsx: 선택된 연도로 데이터 조회
|
||||||
|
*/
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useEffect, Suspense } from 'react'
|
import React, { createContext, useContext, useState, useEffect, Suspense } from 'react'
|
||||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { createContext, useContext, ReactNode } from 'react'
|
|
||||||
import { useFilterStore } from '@/store/filter-store'
|
|
||||||
import { GlobalFilterSettings } from '@/types/filter.types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GlobalFilterContext - Zustand store 래퍼
|
|
||||||
* 기존 코드 호환성을 위해 Context API 인터페이스 유지
|
|
||||||
*/
|
|
||||||
interface GlobalFilterContextType {
|
|
||||||
filters: GlobalFilterSettings
|
|
||||||
updateFilters: (newFilters: Partial<GlobalFilterSettings>) => void
|
|
||||||
resetFilters: () => void
|
|
||||||
isLoading: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const GlobalFilterContext = createContext<GlobalFilterContextType | undefined>(undefined)
|
|
||||||
|
|
||||||
export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
|
||||||
const { filters, updateFilters, resetFilters, isLoading } = useFilterStore()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GlobalFilterContext.Provider value={{ filters, updateFilters, resetFilters, isLoading }}>
|
|
||||||
{children}
|
|
||||||
</GlobalFilterContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGlobalFilter() {
|
|
||||||
const context = useContext(GlobalFilterContext)
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useGlobalFilter must be used within a GlobalFilterProvider')
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* useMediaQuery - CSS 미디어 쿼리 상태 감지 훅
|
||||||
|
*
|
||||||
|
* 사용처:
|
||||||
|
* - cow/[cowNo]/page.tsx: 반응형 레이아웃 처리
|
||||||
|
* - category-evaluation-card.tsx: 반응형 UI 처리
|
||||||
|
*/
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
export function useMediaQuery(query: string) {
|
export function useMediaQuery(query: string) {
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* useIsMobile - 모바일 화면 감지 훅 (768px 미만)
|
||||||
|
*
|
||||||
|
* 사용처:
|
||||||
|
* - sidebar.tsx: 모바일에서 사이드바 동작 변경
|
||||||
|
*/
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* useToast - 토스트 알림 훅 (shadcn/ui)
|
||||||
|
*
|
||||||
|
* 사용처:
|
||||||
|
* - cow/[cowNo]/page.tsx: 에러/성공 알림
|
||||||
|
* - reproduction/page.tsx: 데이터 로드 실패 알림
|
||||||
|
* - mpt/page.tsx: 검색 결과 알림
|
||||||
|
* - toaster.tsx: 토스트 렌더링
|
||||||
|
*/
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
|
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
LoginDto,
|
LoginDto,
|
||||||
AuthResponseDto,
|
AuthResponseDto,
|
||||||
UserProfileDto,
|
UserProfileDto,
|
||||||
UpdateProfileDto,
|
|
||||||
} from '@/types/auth.types';
|
} from '@/types/auth.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,21 +65,6 @@ export const authApi = {
|
|||||||
return await apiClient.get('/users/profile');
|
return await apiClient.get('/users/profile');
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* 프로필 수정 : 미구현
|
|
||||||
*/
|
|
||||||
updateProfile: async (dto: UpdateProfileDto): Promise<UserProfileDto> => {
|
|
||||||
// 인터셉터가 자동으로 언래핑
|
|
||||||
return await apiClient.patch('/users/profile', dto);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 토큰 갱신
|
|
||||||
*/
|
|
||||||
refreshToken: async (refreshToken: string): Promise<AuthResponseDto> => {
|
|
||||||
// 인터셉터가 자동으로 언래핑
|
|
||||||
return await apiClient.post('/auth/refresh', { refreshToken });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 비밀번호 변경
|
* 비밀번호 변경
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
import apiClient from '../api-client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 교배 조합 저장 관련 API
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface BreedSave {
|
|
||||||
pkSaveNo: number;
|
|
||||||
fkUserNo: number;
|
|
||||||
fkCowNo: string;
|
|
||||||
fkKpnNo: string;
|
|
||||||
saveMemo?: string;
|
|
||||||
delYn: 'Y' | 'N';
|
|
||||||
regDt: Date;
|
|
||||||
updtDt: Date;
|
|
||||||
scheduledDate?: string; // 교배 예정일 (선택)
|
|
||||||
completed?: boolean; // 교배 완료 여부 (선택)
|
|
||||||
completedDate?: string; // 교배 완료일 (선택)
|
|
||||||
cow?: any;
|
|
||||||
kpn?: any;
|
|
||||||
user?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateBreedSaveDto {
|
|
||||||
fkUserNo: number;
|
|
||||||
fkCowNo: string;
|
|
||||||
fkKpnNo: string;
|
|
||||||
saveMemo?: string;
|
|
||||||
scheduledDate?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateBreedSaveDto {
|
|
||||||
saveMemo?: string;
|
|
||||||
scheduledDate?: string;
|
|
||||||
completed?: boolean;
|
|
||||||
completedDate?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FilterBreedSaveDto {
|
|
||||||
fkUserNo?: number;
|
|
||||||
fkCowNo?: string;
|
|
||||||
fkKpnNo?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
sortBy?: string;
|
|
||||||
sortOrder?: 'ASC' | 'DESC';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const breedApi = {
|
|
||||||
/**
|
|
||||||
* POST /breed - 교배 조합 저장
|
|
||||||
*/
|
|
||||||
create: async (data: CreateBreedSaveDto): Promise<BreedSave> => {
|
|
||||||
return await apiClient.post('/breed', data) as unknown as BreedSave;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /breed - 교배 조합 목록 조회 (필터링 + 페이징)
|
|
||||||
*/
|
|
||||||
findAll: async (filter?: FilterBreedSaveDto): Promise<{
|
|
||||||
data: BreedSave[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
}> => {
|
|
||||||
return await apiClient.get('/breed', { params: filter });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /breed/search - 교배 조합 검색
|
|
||||||
*
|
|
||||||
* @param keyword - 검색어 (개체번호, KPN번호, 메모)
|
|
||||||
* @param userNo - 사용자 번호 (선택)
|
|
||||||
* @param limit - 결과 제한 (기본 20)
|
|
||||||
*/
|
|
||||||
search: async (keyword: string, userNo?: number, limit: number = 20): Promise<BreedSave[]> => {
|
|
||||||
return await apiClient.get('/breed/search', {
|
|
||||||
params: { keyword, userNo, limit },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /breed/:id - 교배 조합 단건 조회
|
|
||||||
*/
|
|
||||||
findOne: async (id: number): Promise<BreedSave> => {
|
|
||||||
return await apiClient.get(`/breed/${id}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /breed/cow/:cowNo - 암소별 교배 조합 조회
|
|
||||||
*/
|
|
||||||
findByCow: async (cowNo: string): Promise<BreedSave[]> => {
|
|
||||||
return await apiClient.get(`/breed/cow/${cowNo}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /breed/kpn/:kpnNo - KPN별 교배 조합 조회
|
|
||||||
*/
|
|
||||||
findByKpn: async (kpnNo: string): Promise<BreedSave[]> => {
|
|
||||||
return await apiClient.get(`/breed/kpn/${kpnNo}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /breed/user/:userNo - 사용자별 교배 조합 조회
|
|
||||||
*/
|
|
||||||
findByUser: async (userNo: number): Promise<BreedSave[]> => {
|
|
||||||
return await apiClient.get(`/breed/user/${userNo}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /breed/date-range/:startDate/:endDate - 날짜 범위로 조회
|
|
||||||
*/
|
|
||||||
findByDateRange: async (startDate: string, endDate: string): Promise<BreedSave[]> => {
|
|
||||||
return await apiClient.get(`/breed/date-range/${startDate}/${endDate}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PATCH /breed/:id - 교배 조합 수정
|
|
||||||
*/
|
|
||||||
update: async (id: number, data: UpdateBreedSaveDto): Promise<BreedSave> => {
|
|
||||||
return await apiClient.patch(`/breed/${id}`, data);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /breed/:id - 교배 조합 삭제 (소프트 삭제)
|
|
||||||
*/
|
|
||||||
remove: async (id: number): Promise<void> => {
|
|
||||||
await apiClient.delete(`/breed/${id}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user