미사용 파일정리

This commit is contained in:
2025-12-24 08:25:44 +09:00
parent 1644fcf241
commit 05d89fdfcd
120 changed files with 817 additions and 85913 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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

View File

@@ -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

View File

@@ -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 데이터 생성 완료
-- 마스터 데이터만 포함, 실제 개체 데이터는 파일 업로드로 생성 예정

View File

@@ -7,16 +7,12 @@ import { AuthModule } from './auth/auth.module';
import { UserModule } from './user/user.module';
import { CommonModule } from './common/common.module';
import { SharedModule } from './shared/shared.module';
import { HelpModule } from './help/help.module';
import { JwtModule } from './common/jwt/jwt.module';
import { JwtStrategy } from './common/jwt/jwt.strategy';
// 새로 생성한 모듈들
import { FarmModule } from './farm/farm.module';
import { CowModule } from './cow/cow.module';
import { GenomeModule } from './genome/genome.module';
import { MptModule } from './mpt/mpt.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { GeneModule } from './gene/gene.module';
import { SystemModule } from './system/system.module';
@@ -56,10 +52,8 @@ import { SystemModule } from './system/system.module';
GenomeModule,
GeneModule,
MptModule,
DashboardModule,
// 기타
HelpModule,
SystemModule,
],
controllers: [AppController],

View File

@@ -77,18 +77,12 @@ export class AuthService {
};
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}`);
return {
message: '로그인 성공',
accessToken,
refreshToken,
user: {
pkUserNo: user.pkUserNo,
userId: user.userId,

View File

@@ -4,7 +4,6 @@
export class LoginResponseDto {
message: string;
accessToken?: string;
refreshToken?: string;
user: {
pkUserNo: number;
userId: string;

View File

@@ -1,10 +1,8 @@
import { Module } from '@nestjs/common';
import { CommonController } from './common.controller';
import { CommonService } from './common.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtStrategy } from './jwt/jwt.strategy';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { HttpExceptionFilter } from './filters/http-exception.filter';
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
import { LoggingInterceptor } from './interceptors/logging.interceptor';
import { TransformInterceptor } from './interceptors/transform.interceptor';
@@ -15,7 +13,6 @@ import { TransformInterceptor } from './interceptors/transform.interceptor';
CommonService,
JwtStrategy,
JwtAuthGuard,
HttpExceptionFilter,
AllExceptionsFilter,
LoggingInterceptor,
TransformInterceptor,
@@ -23,7 +20,6 @@ import { TransformInterceptor } from './interceptors/transform.interceptor';
exports: [
JwtStrategy,
JwtAuthGuard,
HttpExceptionFilter,
AllExceptionsFilter,
LoggingInterceptor,
TransformInterceptor,

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -1,6 +0,0 @@
// 계정 상태 Enum
export enum AccountStatusType {
ACTIVE = "ACTIVE", // 정상
INACTIVE = "INACTIVE", // 비활성
SUSPENDED = "SUSPENDED", // 정지
}

View File

@@ -1,5 +0,0 @@
// 개체 타입 Enum
export enum AnimalType {
COW = 'COW', // 개체
KPN = 'KPN', // KPN
}

View File

@@ -1,12 +0,0 @@
/**
* 분석 현황 상태 값 Enum
*
* @export
* @enum {number}
*/
export enum AnlysStatType {
MATCH = '친자일치',
MISMATCH = '친자불일치',
IMPOSSIBLE = '분석불가',
NO_HISTORY = '이력제부재',
}

View File

@@ -1,13 +0,0 @@
/**
* 사육/도태 추천 타입 Enum
*
* @export
* @enum {string}
*/
export enum BreedingRecommendationType {
/** 사육 추천 */
BREED = '사육추천',
/** 도태 추천 */
CULL = '도태추천',
}

View File

@@ -1,7 +0,0 @@
// 개체 번식 타입 Enum
export enum CowReproType {
DONOR = "공란우",
RECIPIENT = "수란우",
AI = "인공수정",
CULL = "도태대상",
}

View File

@@ -1,7 +0,0 @@
// 개체 상태 Enum
export enum CowStatusType {
NORMAL = "정상",
DEAD = "폐사",
SLAUGHTER = "도축",
SALE = "매각",
}

View File

@@ -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 = '마커정보',
}

View File

@@ -1,179 +1,257 @@
/**
* MPT ()
* MPT
* - API로
*/
export interface MptReferenceRange {
key: string;
name: string; // 한글 표시명
upperLimit: number | null;
lowerLimit: number | null;
unit: string;
category: '에너지' | '단백질' | '간기능' | '미네랄' | '별도';
category: 'energy' | 'protein' | 'liver' | 'mineral' | 'etc';
categoryName: string; // 카테고리 한글명
description?: string; // 항목 설명 (선택)
}
export interface MptCategory {
key: string;
name: string;
color: string;
items: string[];
}
/**
* MPT
*/
export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
// 에너지 카테고리
glucose: {
key: 'glucose',
name: '혈당',
upperLimit: 84,
lowerLimit: 40,
unit: 'mg/dL',
category: '에너지',
category: 'energy',
categoryName: '에너지 대사',
description: '에너지 대사 상태 지표',
},
cholesterol: {
key: 'cholesterol',
name: '콜레스테롤',
upperLimit: 252,
lowerLimit: 74,
unit: 'mg/dL',
category: '에너지',
category: 'energy',
categoryName: '에너지 대사',
description: '혈액 내 콜레스테롤 수치',
},
nefa: {
key: 'nefa',
name: '유리지방산(NEFA)',
upperLimit: 660,
lowerLimit: 115,
unit: 'μEq/L',
category: '에너지',
category: 'energy',
categoryName: '에너지 대사',
description: '혈액 내 유리지방산 수치',
},
bcs: {
key: 'bcs',
name: 'BCS',
upperLimit: 3.5,
lowerLimit: 2.5,
unit: '-',
category: '에너지',
unit: '',
category: 'energy',
categoryName: '에너지 대사',
description: '체충실지수(Body Condition Score)',
},
// 단백질 카테고리
totalProtein: {
key: 'totalProtein',
name: '총단백질',
upperLimit: 7.7,
lowerLimit: 6.2,
unit: 'g/dL',
category: '단백질',
category: 'protein',
categoryName: '단백질 대사',
description: '혈액 내 총단백질 수치',
},
albumin: {
key: 'albumin',
name: '알부민',
upperLimit: 4.3,
lowerLimit: 3.3,
unit: 'g/dL',
category: '단백질',
category: 'protein',
categoryName: '단백질 대사',
description: '혈액 내 알부민 수치',
},
globulin: {
key: 'globulin',
name: '총글로불린',
upperLimit: 36.1,
lowerLimit: 9.1,
unit: 'g/dL',
category: '단백질',
category: 'protein',
categoryName: '단백질 대사',
description: '혈액 내 총글로불린 수치',
},
agRatio: {
name: 'A/G ',
key: 'agRatio',
name: 'A/G 비율',
upperLimit: 0.4,
lowerLimit: 0.1,
unit: '-',
category: '단백질',
description: '혈액 내 A/G 수치',
unit: '',
category: 'protein',
categoryName: '단백질 대사',
description: '알부민/글로불린 비율',
},
bun: {
key: 'bun',
name: '요소태질소(BUN)',
upperLimit: 18.9,
lowerLimit: 11.7,
unit: 'mg/dL',
category: '단백질',
category: 'protein',
categoryName: '단백질 대사',
description: '혈액 내 요소태질소 수치',
},
// 간기능 카테고리
ast: {
key: 'ast',
name: 'AST',
upperLimit: 92,
lowerLimit: 47,
unit: 'U/L',
category: '간기능',
category: 'liver',
categoryName: '간기능',
description: '혈액 내 AST 수치',
},
ggt: {
key: 'ggt',
name: 'GGT',
upperLimit: 32,
lowerLimit: 11,
unit: 'U/L',
category: '간기능',
category: 'liver',
categoryName: '간기능',
description: '혈액 내 GGT 수치',
},
fattyLiverIdx: {
key: 'fattyLiverIdx',
name: '지방간 지수',
upperLimit: 9.9,
lowerLimit: -1.2,
unit: '-',
category: '간기능',
unit: '',
category: 'liver',
categoryName: '간기능',
description: '혈액 내 지방간 지수 수치',
},
// 미네랄 카테고리
calcium: {
key: 'calcium',
name: '칼슘',
upperLimit: 10.6,
lowerLimit: 8.1,
unit: 'mg/dL',
category: '미네랄',
category: 'mineral',
categoryName: '미네랄',
description: '혈액 내 칼슘 수치',
},
phosphorus: {
key: 'phosphorus',
name: '인',
upperLimit: 8.9,
lowerLimit: 6.2,
unit: 'mg/dL',
category: '미네랄',
category: 'mineral',
categoryName: '미네랄',
description: '혈액 내 인 수치',
},
caPRatio: {
key: 'caPRatio',
name: '칼슘/인 비율',
upperLimit: 1.3,
lowerLimit: 1.2,
unit: '-',
category: '미네랄',
description: '혈액 내 칼슘/인 비율 수치',
unit: '',
category: 'mineral',
categoryName: '미네랄',
description: '혈액 내 칼슘/인 비율',
},
magnesium: {
key: 'magnesium',
name: '마그네슘',
upperLimit: 3.3,
lowerLimit: 1.6,
unit: 'mg/dL',
category: '미네랄',
category: 'mineral',
categoryName: '미네랄',
description: '혈액 내 마그네슘 수치',
},
// 별도 카테고리
creatine: {
key: 'creatine',
name: '크레아틴',
upperLimit: 1.3,
lowerLimit: 1.0,
unit: 'mg/dL',
category: '별도',
category: 'etc',
categoryName: '기타',
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(
value: number,
itemKey: string
export function checkMptStatus(
value: number | null,
itemKey: string,
): '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) {
return 'unknown';
}
@@ -182,20 +260,3 @@ export function isWithinRange(
if (value < reference.lowerLimit) return 'low';
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;
}

View File

@@ -0,0 +1,13 @@
/**
* 랭킹 기준 타입 Enum
*
* @description
* 개체 목록 페이지에서 사용하는 랭킹 기준
*
* @export
* @enum {string}
*/
export enum RankingCriteriaType {
/** 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균) */
GENOME = 'GENOME',
}

View 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] ?? '기타';
}

View File

@@ -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;
},
);

View File

@@ -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;
},
);

View File

@@ -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';
}
}
}

View File

@@ -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';
}

View File

@@ -3,37 +3,22 @@
* 개체(Cow) 컨트롤러
* ============================================================
*
* 사용 페이지: 개체 목록 페이지 (/cow)
* 사용 페이지: 개체 목록 페이지 (/cow), 개체 상세 페이지 (/cow/:cowNo)
*
* 엔드포인트:
* - GET /cow - 기본 개체 목록 조회
* - GET /cow/:id - 개체 상세 조회
* - GET /cow/:cowId - 개체 상세 조회
* - 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 { CowModel } from './entities/cow.entity';
import { RankingRequestDto } from './dto/ranking-request.dto';
@Controller('cow')
export class CowController {
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
* 랭킹이 적용된 개체 목록 조회
@@ -45,25 +30,6 @@ export class CowController {
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
* 개체 상세 조회 (cowId: 개체식별번호 KOR로 시작)
@@ -72,19 +38,4 @@ export class CowController {
findOne(@Param('cowId') cowId: string) {
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);
}
}

View File

@@ -3,14 +3,12 @@
* 개체(Cow) 서비스
* ============================================================
*
* 사용 페이지: 개체 목록 페이지 (/cow)
* 사용 페이지: 개체 목록 페이지 (/cow), 개체 상세 페이지 (/cow/:cowNo)
*
* 주요 기능:
* 1. 기본 개체 목록 조회 (findAll, findByFarmId)
* 2. 개체 단건 조회 (findOne, findByCowId)
* 3. 랭킹 적용 개체 목록 조회 (findAllWithRanking)
* 1. 개체 단건 조회 (findByCowId)
* 2. 랭킹 적용 개체 목록 조회 (findAllWithRanking)
* - GENOME: 35개 형질 EBV 가중 평균
* 4. 개체 CRUD (create, update, remove)
* ============================================================
*/
import { Injectable, NotFoundException } from '@nestjs/common';
@@ -25,16 +23,10 @@ import { FilterEngineService } from '../shared/filter/filter-engine.service';
import {
RankingRequestDto,
RankingCriteriaType,
TraitRankingCondition,
TraitRankingConditionDto,
} from './dto/ranking-request.dto';
import { isValidGenomeAnalysis, EXCLUDED_COW_IDS } from '../common/config/GenomeAnalysisConfig';
/**
* 낮을수록 좋은 형질 목록 (부호 반전 필요)
* - 등지방두께: 지방이 얇을수록(EBV가 낮을수록) 좋은 형질
* - 선발지수 계산 시 EBV 부호를 반전하여 적용
*/
const NEGATIVE_TRAITS = ['등지방두께'];
import { ALL_TRAITS, NEGATIVE_TRAITS } from '../common/const/TraitTypes';
/**
* 개체(소) 관리 서비스
@@ -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)로 단건 조회
*
@@ -187,6 +132,8 @@ export class CowService {
// Step 3: 랭킹 기준에 따라 분기 처리
switch (criteriaType) {
// 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
// 지금은 유전체 형질만 기반으로 랭킹을 매기고 있음 추후 유전자와 유전체 복합 랭킹 변경될수있음
// case 추가 예정
case RankingCriteriaType.GENOME:
return this.applyGenomeRanking(cows, rankingOptions.traitConditions || [], mptCowIdMap);
@@ -331,20 +278,9 @@ export class CowService {
*/
private async applyGenomeRanking(
cows: CowModel[],
inputTraitConditions: TraitRankingCondition[],
inputTraitConditions: TraitRankingConditionDto[],
mptCowIdMap: Map<string, { testDt: string; monthAge: number }>,
): Promise<any> {
// 35개 전체 형질 (기본값)
const ALL_TRAITS = [
'12개월령체중',
'도체중', '등심단면적', '등지방두께', '근내지방도',
'체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위',
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
];
// traitConditions가 비어있으면 35개 전체 형질 사용 (개체상세, 대시보드와 동일)
const traitConditions = inputTraitConditions && inputTraitConditions.length > 0
? inputTraitConditions
@@ -356,7 +292,9 @@ export class CowService {
// Step 1: 해당 개체의 최신 유전체 분석 의뢰 조회 (친자감별 확인용)
const latestRequest = await this.genomeRequestRepository.findOne({
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
order: { requestDt: 'DESC', regDt: 'DESC' },
order: {
requestDt: 'DESC',
regDt: 'DESC' },
});
// Step 2: 친자감별 확인 - 유효하지 않으면 분석 불가
@@ -418,7 +356,7 @@ export class CowService {
};
}
// Step 4: 가중 합계 계산
// Step 4: 가중 합계 계산 ====================================================
let weightedSum = 0; // 가중치 적용된 EBV 합계
let totalWeight = 0; // 총 가중치
let hasAllTraits = true; // 모든 선택 형질 존재 여부
@@ -457,7 +395,7 @@ export class CowService {
? weightedSum // 가중 합계 (개체상세, 대시보드와 동일한 방식)
: null;
// Step 7: 응답 데이터 구성
// Step 7: 응답 데이터 구성 (반환 값)
const mptData = mptCowIdMap.get(cow.cowId);
return {
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);
}
}

View File

@@ -12,69 +12,30 @@
* ============================================================
*/
/**
* 랭킹 기준 타입
* - GENOME: 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
*/
export enum RankingCriteriaType {
GENOME = 'GENOME',
}
import {
IsEnum,
IsOptional,
IsArray,
IsString,
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에서 사용)
// ============================================================
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;
}
// ============================================================
// 랭킹 조건 타입
// 랭킹 조건 DTO
// ============================================================
/**
@@ -84,21 +45,62 @@ export interface FilterEngineOptions {
*
* 예: { traitNm: '도체중', weight: 8 }
*/
export interface TraitRankingCondition {
traitNm: string; // 형질명 (예: '도체중', '근내지방도')
weight?: number; // 가중치 1~10 (기본값: 1)
export class TraitRankingConditionDto {
@IsString()
traitNm: string; // 형질명 (예: '도체중', '근내지방도')
@IsOptional()
@IsNumber()
@Min(1)
@Max(10)
weight?: number; // 가중치 1~10 (기본값: 1)
}
/**
* 랭킹 옵션
* 랭킹 옵션 DTO
*/
export interface RankingOptions {
criteriaType: RankingCriteriaType; // 랭킹 기준 타입
traitConditions?: TraitRankingCondition[]; // GENOME용: 형질별 가중치
export class RankingOptionsDto {
@IsEnum(RankingCriteriaType)
criteriaType: RankingCriteriaType; // 랭킹 기준 타입
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => TraitRankingConditionDto)
traitConditions?: TraitRankingConditionDto[]; // GENOME용: 형질별 가중치
@IsOptional()
@IsNumber()
@Min(1)
limit?: number;
@IsOptional()
@IsNumber()
@Min(0)
offset?: number;
}
// ============================================================
// 필터 옵션 DTO (FilterEngine용)
// ============================================================
/**
* 필터 엔진 옵션 DTO
* - 개체 목록 필터링에 사용
*/
export class FilterEngineOptionsDto implements FilterEngineOptions {
@IsOptional()
@IsArray()
filters?: FilterCondition[];
@IsOptional()
@IsArray()
sorts?: SortOption[];
@IsOptional()
pagination?: PaginationOption;
}
// ============================================================
// 메인 요청 DTO
// ============================================================
@@ -123,7 +125,13 @@ export interface RankingOptions {
* }
* }
*/
export interface RankingRequestDto {
filterOptions?: FilterEngineOptions; // 필터/정렬/페이지네이션
rankingOptions: RankingOptions; // 랭킹 조건
export class RankingRequestDto {
@IsOptional()
@ValidateNested()
@Type(() => FilterEngineOptionsDto)
filterOptions?: FilterEngineOptionsDto; // 필터/정렬/페이지네이션
@ValidateNested()
@Type(() => RankingOptionsDto)
rankingOptions: RankingOptionsDto; // 랭킹 조건
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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,
},
};
}
}

View File

@@ -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;
}

View File

@@ -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 { FarmModel } from './entities/farm.entity';
@Controller('farm')
export class FarmController {
@@ -13,40 +12,4 @@ export class FarmController {
}
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);
}
}

View File

@@ -3,17 +3,9 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { FarmController } from './farm.controller';
import { FarmService } from './farm.service';
import { FarmModel } from './entities/farm.entity';
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
import { CowModel } from '../cow/entities/cow.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
FarmModel,
GenomeRequestModel,
CowModel,
]),
],
imports: [TypeOrmModule.forFeature([FarmModel])],
controllers: [FarmController],
providers: [FarmService],
exports: [FarmService],

View File

@@ -1,22 +1,13 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
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()
export class FarmService {
constructor(
@InjectRepository(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' },
});
}
// 농장 단건 조회
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];
}
}

View File

View File

@@ -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 { GeneDetailModel } from './entities/gene-detail.entity';
@@ -14,53 +14,4 @@ export class GeneController {
async findByCowId(@Param('cowId') cowId: string): Promise<GeneDetailModel[]> {
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);
}
}

View File

@@ -1,16 +1,13 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { GeneDetailModel } from './entities/gene-detail.entity';
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
@Injectable()
export class GeneService {
constructor(
@InjectRepository(GeneDetailModel)
private readonly geneDetailRepository: Repository<GeneDetailModel>,
@InjectRepository(GenomeRequestModel)
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
) {}
/**
@@ -19,7 +16,7 @@ export class GeneService {
* @returns 유전자 상세 정보 배열
*/
async findByCowId(cowId: string): Promise<GeneDetailModel[]> {
const results = await this.geneDetailRepository.find({
return await this.geneDetailRepository.find({
where: {
cowId,
delDt: IsNull(),
@@ -29,100 +26,5 @@ export class GeneService {
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);
}
}

View File

View File

@@ -1,8 +1,5 @@
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
import { Public } from '../common/decorators/public.decorator';
import { GenomeService } from './genome.service';
import { GenomeRequestModel } from './entities/genome-request.entity';
import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity';
export interface CategoryAverageDto {
category: string;
@@ -30,16 +27,6 @@ export class GenomeController {
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
* 농가의 보은군 내 순위 조회 (대시보드용)
@@ -67,21 +54,6 @@ export class GenomeController {
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
* 개체식별번호(KOR...)로 유전체 분석 의뢰 정보 조회
@@ -92,11 +64,6 @@ export class GenomeController {
return this.genomeService.findRequestByCowIdentifier(cowId);
}
@Post('request')
createRequest(@Body() data: Partial<GenomeRequestModel>) {
return this.genomeService.createRequest(data);
}
/**
* GET /genome/comparison-averages/:cowId
* 개체 기준 전국/지역/농장 카테고리별 평균 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
* 연도별 유전능력 추이 (형질별/카테고리별)

View File

@@ -5,6 +5,12 @@ import {
isValidGenomeAnalysis,
VALID_CHIP_SIRE_NAME
} 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 { FarmModel } from '../farm/entities/farm.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 { 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
*/
@@ -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) 관련 메서드
// ============================================
/**
* 전체 유전체 분석 의뢰 목록 조회
*
* @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)로 유전체 데이터 조회
* 가장 최근 분석 의뢰의 형질 상세 정보까지 포함하여 반환
@@ -991,19 +735,7 @@ export class GenomeService {
return [];
}
// 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: 프론트엔드에서 사용할 형식으로 데이터 가공하여 반환
// Step 4: 프론트엔드에서 사용할 형식으로 데이터 가공하여 반환
return [{
request: latestRequest, // 분석 의뢰 정보
genomeCows: traitDetails.map(detail => ({
@@ -1012,27 +744,13 @@ export class GenomeService {
percentile: detail.traitPercentile, // 백분위 순위
traitInfo: {
traitNm: detail.traitName, // 형질명
traitCtgry: getCategoryFromTraitName(detail.traitName || ''), // 카테고리
traitCtgry: getTraitCategory(detail.traitName || ''), // 카테고리 (공통 함수 사용)
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)로 유전체 분석 의뢰 정보 조회
*
@@ -1059,61 +777,6 @@ export class GenomeService {
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) 관련 메서드
// ============================================
@@ -1874,17 +1537,7 @@ export class GenomeService {
traitDetailsByCowId.get(detail.cowId)!.push(detail);
}
// 4. 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',
];
// inputTraitConditions가 있으면 사용, 없으면 35개 형질 기본값 사용
// 4. inputTraitConditions가 있으면 사용, 없으면 35개 형질 기본값 사용 (ALL_TRAITS는 공통 상수에서 import)
const traitConditions = inputTraitConditions && inputTraitConditions.length > 0
? inputTraitConditions // 프론트에서 보낸 형질사용
: 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 쿼리 문제 해결 - 단일 쿼리로 모든 데이터 조회

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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) {}

View File

@@ -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
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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);
}
}

View File

@@ -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 { MptModel } from './entities/mpt.entity';
@Controller('mpt')
export class MptController {
constructor(private readonly mptService: MptService) {}
/**
* MPT 참조값 조회
*/
@Get('reference')
getReferenceValues() {
return this.mptService.getReferenceValues();
}
@Get()
findAll(
@Query('farmId') farmId?: string,
@Query('cowShortNo') cowShortNo?: string,
@Query('cowId') cowId?: string,
) {
if (farmId) {
return this.mptService.findByFarmId(+farmId);
}
if (cowId) {
return this.mptService.findByCowId(cowId);
}
if (cowShortNo) {
return this.mptService.findByCowShortNo(cowShortNo);
}
return this.mptService.findAll();
return [];
}
/**
* 농장별 MPT 통계 조회
* - 카테고리별 정상/주의/위험 개체 수
* - 위험 개체 목록
*/
@Get('statistics/:farmNo')
getMptStatistics(@Param('farmNo') farmNo: string) {
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);
}
}

View File

@@ -1,29 +1,13 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { MptModel } from './entities/mpt.entity';
/**
* MPT 참조값 범위 (정상/주의/위험 판단 기준)
*/
const MPT_REFERENCE_RANGES: Record<string, { upper: number; lower: number; category: string }> = {
glucose: { lower: 40, upper: 84, category: 'energy' },
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' },
};
import {
MPT_REFERENCE_RANGES,
MPT_CATEGORIES,
MptReferenceRange,
MptCategory,
} from '../common/const/MptReference';
/**
* MPT 통계 응답 DTO
@@ -53,22 +37,6 @@ export class MptService {
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[]> {
return this.mptRepository.find({
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 통계 조회
* - 개체별 최신 검사 결과 기준
@@ -187,7 +123,7 @@ export class MptService {
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';
// 주의 개체 목록에 추가
@@ -196,7 +132,7 @@ export class MptService {
category,
itemName: itemKey,
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 refB = MPT_REFERENCE_RANGES[b.itemName];
const deviationA = a.status === 'high'
? (a.value - refA.upper) / (refA.upper - refA.lower)
: (refA.lower - a.value) / (refA.upper - refA.lower);
? (a.value - refA.upperLimit) / (refA.upperLimit - refA.lowerLimit)
: (refA.lowerLimit - a.value) / (refA.upperLimit - refA.lowerLimit);
const deviationB = b.status === 'high'
? (b.value - refB.upper) / (refB.upper - refB.lower)
: (refB.lower - b.value) / (refB.upper - refB.lower);
? (b.value - refB.upperLimit) / (refB.upperLimit - refB.lowerLimit)
: (refB.lowerLimit - b.value) / (refB.upperLimit - refB.lowerLimit);
return deviationB - deviationA;
})
.slice(0, 5);
@@ -255,4 +191,14 @@ export class MptService {
riskyCows: sortedRiskyCows,
};
}
/**
* MPT 참조값 조회
*/
getReferenceValues(): { references: Record<string, MptReferenceRange>; categories: MptCategory[] } {
return {
references: MPT_REFERENCE_RANGES,
categories: MPT_CATEGORIES,
};
}
}

View File

@@ -6,6 +6,7 @@
FilterEngineResult,
SortOption,
} from './interfaces/filter.interface';
import { PAGINATION_CONFIG } from '../../common/config/PaginationConfig';
/**
* 동적 필터링, 정렬, 페이지네이션을 제공하는 공통 엔진
@@ -160,11 +161,15 @@
// 3. 전체 개수 조회 (페이지네이션 전)
const total = await queryBuilder.getCount();
// 4. 페이지네이션 적용
if (options.pagination) {
const { page, limit } = options.pagination;
this.applyPagination(queryBuilder, page, limit);
}
// 4. 페이지네이션 적용 (기본값: PaginationConfig 사용)
const page = options.pagination?.page ?? PAGINATION_CONFIG.LIMITS.DEFAULT_PAGE;
const requestedLimit = options.pagination?.limit ?? PAGINATION_CONFIG.DEFAULTS.COW_LIST;
// 최대값 제한 적용
const limit = Math.min(
Math.max(requestedLimit, PAGINATION_CONFIG.LIMITS.MIN),
PAGINATION_CONFIG.LIMITS.MAX
);
this.applyPagination(queryBuilder, page, limit);
// 5. 데이터 조회
const data = await queryBuilder.getMany();
@@ -173,15 +178,11 @@
const result: FilterEngineResult<T> = {
data,
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;
}
}

View File

@@ -14,7 +14,7 @@ import {
DrawerHeader,
DrawerTitle,
} 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 {
PolarAngleAxis,
@@ -26,34 +26,7 @@ import {
ResponsiveContainer
} from 'recharts'
import { useMediaQuery } from "@/hooks/use-media-query"
// 디폴트로 표시할 주요 형질 목록
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'],
}
import { DEFAULT_TRAITS, ALL_TRAITS, TRAIT_CATEGORIES } from "@/constants/traits"
// 형질명 표시 (전체 이름)
const TRAIT_SHORT_NAMES: Record<string, string> = {
@@ -121,7 +94,6 @@ interface CategoryEvaluationCardProps {
farmAvgZ: number
allTraits?: TraitData[]
cowNo?: string
traitAverages?: FarmTraitComparisonDto | null // 형질별 평균 비교 데이터 (기존)
hideTraitCards?: boolean // 형질 카드 숨김 여부
}
@@ -147,11 +119,10 @@ export function CategoryEvaluationCard({
farmAvgZ,
allTraits = [],
cowNo,
traitAverages,
hideTraitCards = false,
}: CategoryEvaluationCardProps) {
// 차트에 표시할 형질 목록 (커스텀 가능)
const [chartTraits, setChartTraits] = useState<string[]>(DEFAULT_TRAITS)
const [chartTraits, setChartTraits] = useState<string[]>([...DEFAULT_TRAITS])
// 형질 추가 모달/드로어 상태
const [isTraitSelectorOpen, setIsTraitSelectorOpen] = useState(false)
@@ -178,7 +149,7 @@ export function CategoryEvaluationCard({
// 기본값으로 초기화
const resetToDefault = () => {
setChartTraits(DEFAULT_TRAITS)
setChartTraits([...DEFAULT_TRAITS])
}
// 폴리곤 차트용 데이터 생성 (선택된 형질 기반) - 보은군, 농가, 이 개체 비교

View File

@@ -1,11 +1,12 @@
'use client'
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 { useGlobalFilter } from "@/contexts/GlobalFilterContext"
import { useFilterStore } from "@/store/filter-store"
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
import { CowNumberDisplay } from "@/components/common/cow-number-display"
import { ALL_TRAITS } from "@/constants/traits"
// 분포 데이터 타입
interface DistributionBin {
@@ -115,7 +116,7 @@ export function GenomeIntegratedComparison({
}
//===========================================================================================
const { filters } = useGlobalFilter()
const { filters } = useFilterStore()
const { selectedYear } = useAnalysisYear()
const [stats, setStats] = useState<IntegratedStats | null>(null)
const [loading, setLoading] = useState(true)
@@ -132,22 +133,6 @@ export function GenomeIntegratedComparison({
}[]>([])
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 selected = Object.entries(filters.traitWeights)

View File

@@ -16,10 +16,8 @@ import {
YAxis
} from 'recharts'
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
// 낮을수록 좋은 형질 (부호 반전 필요)
const NEGATIVE_TRAITS = ['등지방두께']
import { useFilterStore } from "@/store/filter-store"
import { NEGATIVE_TRAITS } from "@/constants/traits"
// 카테고리 색상 (모던 & 다이나믹 - 생동감 있는 색상)
const CATEGORY_COLORS: Record<string, string> = {
@@ -188,7 +186,7 @@ export function NormalDistributionChart({
chartFilterTrait: externalChartFilterTrait,
onChartFilterTraitChange
}: NormalDistributionChartProps) {
const { filters } = useGlobalFilter()
const { filters } = useFilterStore()
// 필터에서 고정된 첫 번째 형질 (없으면 첫 번째 선택된 형질, 없으면 '도체중')
const firstPinnedTrait = filters.pinnedTraits?.[0] || selectedTraitData[0]?.name || '도체중'

View File

@@ -2,12 +2,7 @@
import { useMemo } from 'react'
import { Card, CardContent } from "@/components/ui/card"
// 기본 7개 형질
const DEFAULT_TRAITS = ['도체중', '등심단면적', '등지방두께', '근내지방도', '체장', '체고', '등심weight']
// 낮을수록 좋은 형질 (부호 반전 색상 적용)
const NEGATIVE_TRAITS = ['등지방두께']
import { DEFAULT_TRAITS, NEGATIVE_TRAITS } from "@/constants/traits"
// 형질명 표시 (전체 이름)
const TRAIT_SHORT_NAMES: Record<string, string> = {

View File

@@ -11,10 +11,13 @@ import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
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 { 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 { CowDetail } from "@/types/cow.types"
import { GenomeTrait } from "@/types/genome.types"
@@ -145,7 +148,7 @@ export default function CowOverviewPage() {
const cowNo = params.cowNo as string
const from = searchParams.get('from')
const { toast } = useToast()
const { filters } = useGlobalFilter()
const { filters } = useFilterStore()
const isMobile = useMediaQuery("(max-width: 640px)")
const [cow, setCow] = useState<CowDetail | null>(null)

View File

@@ -3,26 +3,20 @@
import { useEffect, useState } from 'react'
import { Card, CardContent } from "@/components/ui/card"
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 { CowNumberDisplay } from "@/components/common/cow-number-display"
import { CowDetail } from "@/types/cow.types"
import { GenomeRequestDto } from "@/lib/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' },
]
import { GenomeRequestDto } from "@/lib/api/genome.api"
// 측정값 상태 판정: 안전(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'
const ref = MPT_REFERENCE_RANGES[key]
const ref = references[key]
if (!ref || ref.lowerLimit === null || ref.upperLimit === null) return 'unknown'
// 하한값 ~ 상한값 사이면 안전, 그 외 주의
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 [selectedMpt, setSelectedMpt] = useState<MptDto | null>(null)
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(() => {
const fetchMptData = async () => {
@@ -63,7 +76,7 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
fetchMptData()
}, [cowNo])
if (loading) {
if (loading || refLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
@@ -245,11 +258,11 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
</tr>
</thead>
<tbody>
{MPT_CATEGORIES.map((category) => (
{categories.map((category) => (
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 status = getMptValueStatus(itemKey, value)
const status = getMptValueStatus(itemKey, value, references)
return (
<tr key={itemKey} className="border-b border-border hover:bg-muted/30">

View File

@@ -8,15 +8,29 @@ import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
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 { ReproMpt } from "@/types/mpt.types"
import { Activity, AlertCircle, CheckCircle } from "lucide-react"
import { CowNavigation } from "../_components/navigation"
import { useToast } from "@/hooks/use-toast"
import { MPT_REFERENCE_RANGES, isWithinRange } from "@/constants/mpt-reference"
import { AuthGuard } from "@/components/auth/auth-guard"
// 측정값이 정상 범위 내인지 확인
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() {
const params = useParams()
const router = useRouter()
@@ -26,8 +40,25 @@ export default function ReproductionPage() {
const { toast } = useToast()
const [cow, setCow] = useState<CowDetail | null>(null)
const [reproMpt, setReproMpt] = useState<ReproMpt[]>([])
const [mptData, setMptData] = useState<MptDto[]>([])
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(() => {
const fetchData = async () => {
@@ -47,8 +78,8 @@ export default function ReproductionPage() {
// 암소인 경우만 MPT 정보 조회
if (cowData.cowSex === 'F') {
try {
const mptData = await reproApi.findMptByCowNo(cowNo)
setReproMpt(mptData)
const data = await mptApi.findByCowId(cowNo)
setMptData(data)
} catch (err) {
console.error('MPT 정보 조회 실패:', err)
}
@@ -69,7 +100,7 @@ export default function ReproductionPage() {
fetchData()
}, [cowNo, toast])
if (loading || !cow) {
if (loading || refLoading || !cow) {
return (
<SidebarProvider>
<AppSidebar />
@@ -125,27 +156,27 @@ export default function ReproductionPage() {
}
// MPT 데이터 정리
const mptItems = reproMpt.length > 0 ? [
{ name: '글루코스', value: reproMpt[0].bloodSugar, fieldName: 'bloodSugar' },
{ name: '콜레스테롤', value: reproMpt[0].cholesterol, fieldName: 'cholesterol' },
{ name: 'NEFA', value: reproMpt[0].nefa, fieldName: 'nefa' },
{ name: '알부민', value: reproMpt[0].albumin, fieldName: 'albumin' },
{ name: '총글로불린', value: reproMpt[0].totalGlobulin, fieldName: 'totalGlobulin' },
{ name: 'A/G', value: reproMpt[0].agRatio, fieldName: 'agRatio' },
{ name: '요소태질소(BUN)', value: reproMpt[0].bun, fieldName: 'bun' },
{ name: 'AST', value: reproMpt[0].ast, fieldName: 'ast' },
{ name: 'GGT', value: reproMpt[0].ggt, fieldName: 'ggt' },
{ name: '지방간 지수', value: reproMpt[0].fattyLiverIndex, fieldName: 'fattyLiverIndex' },
{ name: '칼슘', value: reproMpt[0].calcium, fieldName: 'calcium' },
{ name: '인', value: reproMpt[0].phosphorus, fieldName: 'phosphorus' },
{ name: '칼슘/인', value: reproMpt[0].caPRatio, fieldName: 'caPRatio' },
{ name: '마그네슘', value: reproMpt[0].magnesium, fieldName: 'magnesium' },
{ name: '크레아틴', value: reproMpt[0].creatine, fieldName: 'creatine' },
const mptItems = mptData.length > 0 ? [
{ name: '혈당', value: mptData[0].glucose, fieldName: 'glucose' },
{ name: '콜레스테롤', value: mptData[0].cholesterol, fieldName: 'cholesterol' },
{ name: 'NEFA', value: mptData[0].nefa, fieldName: 'nefa' },
{ name: '알부민', value: mptData[0].albumin, fieldName: 'albumin' },
{ name: '총글로불린', value: mptData[0].globulin, fieldName: 'globulin' },
{ name: 'A/G', value: mptData[0].agRatio, fieldName: 'agRatio' },
{ name: '요소태질소(BUN)', value: mptData[0].bun, fieldName: 'bun' },
{ name: 'AST', value: mptData[0].ast, fieldName: 'ast' },
{ name: 'GGT', value: mptData[0].ggt, fieldName: 'ggt' },
{ name: '지방간 지수', value: mptData[0].fattyLiverIdx, fieldName: 'fattyLiverIdx' },
{ name: '칼슘', value: mptData[0].calcium, fieldName: 'calcium' },
{ name: '인', value: mptData[0].phosphorus, fieldName: 'phosphorus' },
{ name: '칼슘/인', value: mptData[0].caPRatio, fieldName: 'caPRatio' },
{ name: '마그네슘', value: mptData[0].magnesium, fieldName: 'magnesium' },
{ name: '크레아틴', value: mptData[0].creatine, fieldName: 'creatine' },
] : []
const normalItems = mptItems.filter(item => {
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
@@ -173,7 +204,7 @@ export default function ReproductionPage() {
</div>
{/* MPT 혈액검사 결과 */}
{reproMpt.length > 0 ? (
{mptData.length > 0 ? (
<>
<Card>
<CardHeader>
@@ -202,16 +233,16 @@ export default function ReproductionPage() {
<CardHeader>
<CardTitle>MPT </CardTitle>
<CardDescription>
: {reproMpt[0].reproMptDate
? new Date(reproMpt[0].reproMptDate).toLocaleDateString('ko-KR')
: {mptData[0].testDt
? new Date(mptData[0].testDt).toLocaleDateString('ko-KR')
: '정보 없음'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{mptItems.map((item, idx) => {
const isNormal = item.value !== undefined && item.value !== null && isWithinRange(item.value, item.fieldName) === 'normal'
const reference = MPT_REFERENCE_RANGES[item.fieldName as keyof typeof MPT_REFERENCE_RANGES]
const isNormal = item.value !== undefined && item.value !== null && isWithinRange(item.value, item.fieldName, references) === 'normal'
const reference = references[item.fieldName]
return (
<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>
{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>
</Card>
</>

View File

@@ -19,9 +19,9 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
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 { useGlobalFilter, GlobalFilterProvider } from "@/contexts/GlobalFilterContext"
import { useFilterStore } from "@/store/filter-store"
import { AnalysisYearProvider } from "@/contexts/AnalysisYearContext"
import { AuthGuard } from "@/components/auth/auth-guard"
@@ -70,7 +70,7 @@ function MyCowContent() {
const [error, setError] = useState<string | null>(null)
const router = useRouter()
const { user } = useAuthStore()
const { filters, isLoading: isFilterLoading } = useGlobalFilter()
const { filters, isLoading: isFilterLoading } = useFilterStore()
const [markerTypes, setMarkerTypes] = useState<Record<string, string>>({}) // 마커명 → 타입(QTY/QLT) 매핑
// 로컬 필터 상태 (검색, 랭킹모드, 정렬)
@@ -264,7 +264,7 @@ function MyCowContent() {
},
rankingOptions
}
// 백엔드 ranking API 호출
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: '100px' }}></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' ? '월령(검사일)' : '월령(분석일)'}
</th>
<th className="cow-table-header" style={{ width: '90px' }}>
@@ -1505,11 +1505,11 @@ function MyCowContent() {
export default function MyCowPage() {
return (
<AuthGuard>
<GlobalFilterProvider>
<AnalysisYearProvider>
<MyCowContent />
</AnalysisYearProvider>
</GlobalFilterProvider>
</AuthGuard>
)
}

View File

@@ -13,11 +13,13 @@ import {
SelectTrigger,
SelectValue,
} 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 { mptApi, MptStatisticsDto } from "@/lib/api/mpt.api"
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 {
AlertCircle,
CheckCircle2,
@@ -50,22 +52,10 @@ import {
YAxis
} 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() {
const router = useRouter()
const { user } = useAuthStore()
const { filters } = useGlobalFilter()
const { filters } = useFilterStore()
const [farmNo, setFarmNo] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState<DashboardStatsDto | null>(null)

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import "pretendard/dist/web/static/pretendard-dynamic-subset.css";
import "./globals.css";
import { GlobalFilterProvider } from "@/contexts/GlobalFilterContext";
import { AnalysisYearProvider } from "@/contexts/AnalysisYearContext";
import { Toaster } from "@/components/ui/sonner";
@@ -18,12 +17,10 @@ export default function RootLayout({
return (
<html lang="ko">
<body className="antialiased" style={{ fontFamily: 'Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif' }}>
<GlobalFilterProvider>
<AnalysisYearProvider>
{children}
<Toaster />
</AnalysisYearProvider>
</GlobalFilterProvider>
</body>
</html>
);

View File

@@ -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>
)
}

View File

@@ -15,7 +15,7 @@ export default function Home() {
if (isAuthenticated && accessToken) {
try {
// 프로필 로드로 토큰 유효성 확인
// await loadProfile(); // 👈 주석처리: 백엔드 /users/profile 미구현으로 인한 401 에러 방지
// await loadProfile(); // 백엔드 /users/profile 미구현으로 인한 401 에러 방지
// 성공하면 대시보드로
router.push("/dashboard");
} catch (error) {

View File

@@ -9,9 +9,10 @@ import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
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 { 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 {
DndContext,
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 키 -> 화면 표시용)
const TRAIT_DISPLAY_NAMES: Record<string, string> = {
'안심weight': '안심중량',
@@ -255,7 +208,7 @@ interface 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 open = externalOpen !== undefined ? externalOpen : internalOpen
@@ -271,8 +224,8 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCou
const [openCategories, setOpenCategories] = useState<Record<string, boolean>>({})
// DB에서 가져온 유전자 목록
const [quantityGenes, setQuantityGenes] = useState<MarkerModel[]>([])
const [qualityGenes, setQualityGenes] = useState<MarkerModel[]>([])
const [quantityGenes, setQuantityGenes] = useState<any[]>([])
const [qualityGenes, setQualityGenes] = useState<any[]>([])
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 => {
if (select) {
const newGenes = [...prev.selectedGenes]
@@ -938,14 +891,14 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCou
<div className="space-y-2">
{TRAIT_CATEGORIES.map(cat => {
const isCategoryMatch = traitSearch && cat.name.toLowerCase().includes(traitSearch.toLowerCase())
const filteredTraits = traitSearch
const filteredTraits: string[] = traitSearch
? isCategoryMatch
? cat.traits
: cat.traits.filter(t =>
? [...cat.traits]
: [...cat.traits].filter(t =>
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

View File

@@ -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>
)
}

View File

@@ -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>
))}
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -3,7 +3,7 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useEffect, useState } from "react"
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
import { useFilterStore } from "@/store/filter-store"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Filter, ChevronDown, ChevronUp } from "lucide-react"
@@ -21,7 +21,7 @@ interface GenePossessionStatusProps {
export function GenePossessionStatus({ farmNo }: GenePossessionStatusProps) {
const { selectedYear } = useAnalysisYear()
const { filters } = useGlobalFilter()
const { filters } = useFilterStore()
const [allGenes, setAllGenes] = useState<GeneData[]>([])
const [loading, setLoading] = useState(true)
const [isExpanded, setIsExpanded] = useState(false)

View File

@@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
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"
interface GeneSearchDrawerProps {
@@ -18,7 +18,7 @@ interface 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 [searchQuery, setSearchQuery] = useState("")
const [filterType, setFilterType] = useState<'ALL' | 'QTY' | 'QLT'>('ALL')

View File

@@ -2,7 +2,7 @@
import { PieChart as PieChartIcon } from "lucide-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'
interface DistributionData {

View File

@@ -2,7 +2,7 @@
import { Target } from "lucide-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'
interface TraitScore {

View File

@@ -2,7 +2,7 @@
import { ArrowUpRight, ArrowDownRight, TrendingUp, TrendingDown } from "lucide-react"
import { useEffect, useState } from "react"
import { apiClient } from "@/lib/api"
import apiClient from "@/lib/api-client"
interface GenomeData {
trait: string

View File

@@ -2,7 +2,7 @@
import { BarChart3 } from "lucide-react"
import { useEffect, useState } from "react"
import { apiClient } from "@/lib/api"
import apiClient from "@/lib/api-client"
interface TraitData {
trait: string

View File

@@ -20,7 +20,7 @@ import {
} from "@/components/ui/select";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { useAnalysisYear } from "@/contexts/AnalysisYearContext";
import { useGlobalFilter } from "@/contexts/GlobalFilterContext";
import { useFilterStore } from "@/store/filter-store";
import { useAuthStore } from "@/store/auth-store";
import { LogOut, User } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -28,7 +28,7 @@ import { useRouter } from "next/navigation";
export function SiteHeader() {
const { user, logout } = useAuthStore();
const router = useRouter();
const { filters } = useGlobalFilter();
const { filters } = useFilterStore();
const { selectedYear, setSelectedYear, availableYears } = useAnalysisYear();
const handleLogout = async () => {

View 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;
}

View File

@@ -1,5 +1,18 @@
'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 { useRouter, useSearchParams, usePathname } from 'next/navigation'

View File

@@ -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
}

View File

@@ -1,3 +1,11 @@
/**
* useMediaQuery - CSS 미디어 쿼리 상태 감지 훅
*
* 사용처:
* - cow/[cowNo]/page.tsx: 반응형 레이아웃 처리
* - category-evaluation-card.tsx: 반응형 UI 처리
*/
import * as React from "react"
export function useMediaQuery(query: string) {

View File

@@ -1,3 +1,9 @@
/**
* useIsMobile - 모바일 화면 감지 훅 (768px 미만)
*
* 사용처:
* - sidebar.tsx: 모바일에서 사이드바 동작 변경
*/
import * as React from "react"
const MOBILE_BREAKPOINT = 768

View File

@@ -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 type { ToastActionElement, ToastProps } from "@/components/ui/toast"

View File

@@ -4,7 +4,6 @@ import {
LoginDto,
AuthResponseDto,
UserProfileDto,
UpdateProfileDto,
} from '@/types/auth.types';
/**
@@ -66,21 +65,6 @@ export const authApi = {
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 });
},
/**
* 비밀번호 변경

View File

@@ -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