UI 수정:화면 수정

This commit is contained in:
2025-12-11 20:07:19 +09:00
parent b906ec1851
commit 7d15c9be7c
26 changed files with 2629 additions and 557 deletions

View File

@@ -25,7 +25,10 @@ export const INVALID_CHIP_DAM_NAMES = ['불일치', '이력제부재'];
/** ===============================개별 제외 개체 목록 (분석불가 등 특수 사유) 하단 개체 정보없음 확인필요=================*/ /** ===============================개별 제외 개체 목록 (분석불가 등 특수 사유) 하단 개체 정보없음 확인필요=================*/
export const EXCLUDED_COW_IDS = [ export const EXCLUDED_COW_IDS = [
'KOR002191642861', // 1회 분석 반려내역서 재분석 불가능 'KOR002191642861',
// 일치인데 정보가 없음 / 김정태님 유전체 내역 빠짐 1두
// 일단 모근 1회분량이고 재검사어려움 , 모근상태 불량으로 인한 DNA분해로 인해 분석불가 상태로 넣음
]; ];
//================================================================================================================= //=================================================================================================================

View File

@@ -228,25 +228,36 @@ export class CowService {
// 각 개체별로 점수 계산 // 각 개체별로 점수 계산
const cowsWithScore = await Promise.all( const cowsWithScore = await Promise.all(
cows.map(async (cow) => { cows.map(async (cow) => {
// Step 1: cowId로 직접 형질 상세 데이터 조회 (35개 형질의 EBV 값) // Step 1: 해당 개체의 최신 유전체 분석 의뢰 조회 (친자감별 확인용)
const traitDetails = await this.genomeTraitDetailRepository.find({
where: { cowId: cow.cowId, delDt: IsNull() },
});
// 형질 데이터가 없으면 점수 null
if (traitDetails.length === 0) {
return { entity: cow, sortValue: null, details: [] };
}
// Step 2: 해당 개체의 최신 유전체 분석 의뢰 조회 (친자감별 확인용)
const latestRequest = await this.genomeRequestRepository.findOne({ const latestRequest = await this.genomeRequestRepository.findOne({
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() }, where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
order: { requestDt: 'DESC', regDt: 'DESC' }, order: { requestDt: 'DESC', regDt: 'DESC' },
}); });
// Step 3: 친자감별 확인 - 아비 KPN "일치"가 아니면 분석 불가 // Step 2: 친자감별 확인 - 유효하지 않으면 분석 불가
if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) { if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) {
return { entity: cow, sortValue: null, details: [] }; // 분석불가 사유 결정
let unavailableReason = '미분석';
if (latestRequest) {
if (latestRequest.chipSireName !== '일치') {
unavailableReason = '부 불일치';
} else if (latestRequest.chipDamName === '불일치') {
unavailableReason = '모 불일치';
} else if (latestRequest.chipDamName === '이력제부재') {
unavailableReason = '모 이력제부재';
}
}
return { entity: { ...cow, unavailableReason }, sortValue: null, details: [] };
}
// Step 3: cowId로 직접 형질 상세 데이터 조회 (35개 형질의 EBV 값)
const traitDetails = await this.genomeTraitDetailRepository.find({
where: { cowId: cow.cowId, delDt: IsNull() },
});
// 형질 데이터가 없으면 점수 null (친자는 일치하지만 형질 데이터 없음)
if (traitDetails.length === 0) {
return { entity: { ...cow, unavailableReason: '형질정보없음' }, sortValue: null, details: [] };
} }
// Step 4: 가중 평균 계산 // Step 4: 가중 평균 계산

View File

@@ -45,9 +45,12 @@ export class GenomeController {
* 농가의 보은군 내 순위 조회 (대시보드용) * 농가의 보은군 내 순위 조회 (대시보드용)
* @param farmNo - 농장 번호 * @param farmNo - 농장 번호
*/ */
@Get('farm-region-ranking/:farmNo') @Post('farm-region-ranking/:farmNo')
getFarmRegionRanking(@Param('farmNo') farmNo: string) { getFarmRegionRanking(
return this.genomeService.getFarmRegionRanking(+farmNo); @Param('farmNo') farmNo: string,
@Body() body: { traitConditions?: { traitNm: string; weight?: number }[] }
) {
return this.genomeService.getFarmRegionRanking(+farmNo, body.traitConditions);
} }
/** /**

View File

@@ -1322,13 +1322,18 @@ export class GenomeService {
}; };
} }
// Step 4: 가중 평균 계산 // Step 4: 가중 평균 계산 ================================================================================
let weightedSum = 0; // Σ(EBV × 가중치) let weightedSum = 0; // Σ(EBV × 가중치)
let totalWeight = 0; // Σ(가중치) let totalWeight = 0; // Σ(가중치)
let percentileSum = 0; // 백분위 합계 (평균 계산용) let percentileSum = 0; // 백분위 합계 (평균 계산용)
let percentileCount = 0; // 백분위 개수 let percentileCount = 0; // 백분위 개수
let hasAllTraits = true; // 모든 선택 형질 존재 여부 (리스트와 동일 로직) let hasAllTraits = true; // 모든 선택 형질 존재 여부 (리스트와 동일 로직)
const details: { traitNm: string; ebv: number; weight: number; contribution: number }[] = []; const details: {
traitNm: string;
ebv: number;
weight: number;
contribution: number
}[] = [];
for (const condition of traitConditions) { for (const condition of traitConditions) {
const trait = traitDetails.find((d) => d.traitName === condition.traitNm); const trait = traitDetails.find((d) => d.traitName === condition.traitNm);
@@ -1359,8 +1364,8 @@ export class GenomeService {
} }
} }
// Step 6: 최종 점수 계산 (모든 선택 형질이 있어야만 계산) // Step 6: 최종 점수 계산 (모든 선택 형질이 있어야만 계산) ================================================================
const score = (hasAllTraits && totalWeight > 0) ? weightedSum / totalWeight : null; const score = (hasAllTraits && totalWeight > 0) ? weightedSum : null;
const percentile = percentileCount > 0 ? percentileSum / percentileCount : null; const percentile = percentileCount > 0 ? percentileSum / percentileCount : null;
// Step 7: 현재 개체의 농장/지역 정보 조회 // Step 7: 현재 개체의 농장/지역 정보 조회
@@ -1482,7 +1487,7 @@ export class GenomeService {
// 모든 선택 형질이 있는 경우만 점수에 포함 // 모든 선택 형질이 있는 경우만 점수에 포함
if (hasAllTraits && totalWeight > 0) { if (hasAllTraits && totalWeight > 0) {
const score = weightedSum / totalWeight; const score = weightedSum;
allScores.push({ allScores.push({
cowId: request.cow.cowId, cowId: request.cow.cowId,
score: Math.round(score * 100) / 100, score: Math.round(score * 100) / 100,
@@ -1494,6 +1499,7 @@ export class GenomeService {
// 점수 기준 내림차순 정렬 // 점수 기준 내림차순 정렬
allScores.sort((a, b) => b.score - a.score); allScores.sort((a, b) => b.score - a.score);
console.log('[calculateRanks] 샘플 점수:', allScores.slice(0, 5).map(s => ({ cowId: s.cowId, score: s.score })));
// 농가 순위 및 평균 선발지수 계산 // 농가 순위 및 평균 선발지수 계산
let farmRank: number | null = null; let farmRank: number | null = null;
@@ -1667,7 +1673,10 @@ export class GenomeService {
* 성능 최적화: N+1 쿼리 문제 해결 - 모든 데이터를 한 번에 조회 후 메모리에서 처리 * 성능 최적화: N+1 쿼리 문제 해결 - 모든 데이터를 한 번에 조회 후 메모리에서 처리
* @param farmNo - 농장 번호 * @param farmNo - 농장 번호
*/ */
async getFarmRegionRanking(farmNo: number): Promise<{ async getFarmRegionRanking(
farmNo: number,
inputTraitConditions?: { traitNm: string; weight?: number }[]
): Promise<{
farmNo: number; farmNo: number;
farmerName: string | null; farmerName: string | null;
farmAvgScore: number | null; farmAvgScore: number | null;
@@ -1728,7 +1737,12 @@ export class GenomeService {
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate', '안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate', '우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
]; ];
const traitConditions = ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 })); // inputTraitConditions가 있으면 사용, 없으면 35개 형질 기본값 사용
const traitConditions = inputTraitConditions && inputTraitConditions.length > 0
? inputTraitConditions
: ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 }));
console.log('[getFarmRegionRanking] traitConditions:', traitConditions.length, 'traits');
// 5. 각 개체별 점수 계산 (메모리에서 처리 - DB 쿼리 없음) // 5. 각 개체별 점수 계산 (메모리에서 처리 - DB 쿼리 없음)
const allScores: { cowId: string; score: number; farmNo: number | null }[] = []; const allScores: { cowId: string; score: number; farmNo: number | null }[] = [];
@@ -1758,7 +1772,7 @@ export class GenomeService {
} }
if (hasAllTraits && totalWeight > 0) { if (hasAllTraits && totalWeight > 0) {
const score = weightedSum / totalWeight; const score = weightedSum;
allScores.push({ allScores.push({
cowId: request.cow.cowId, cowId: request.cow.cowId,
score: Math.round(score * 100) / 100, score: Math.round(score * 100) / 100,
@@ -1815,6 +1829,14 @@ export class GenomeService {
? Math.round((allScores.reduce((sum, s) => sum + s.score, 0) / allScores.length) * 100) / 100 ? Math.round((allScores.reduce((sum, s) => sum + s.score, 0) / allScores.length) * 100) / 100
: null; : null;
// 디버깅: 상위 5개 농가 점수 출력
console.log('[getFarmRegionRanking] 결과:', {
myFarmRank: farmRankInRegion,
myFarmScore: myFarmData?.avgScore,
totalFarms: farmAverages.length,
top5: farmAverages.slice(0, 5).map(f => ({ farmNo: f.farmNo, score: f.avgScore }))
});
return { return {
farmNo, farmNo,
farmerName: farm.farmerName || null, farmerName: farm.farmerName || null,

View File

@@ -431,7 +431,7 @@ export function CategoryEvaluationCard({
/> />
{/* 보은군 평균 - Green */} {/* 보은군 평균 - Green */}
<Radar <Radar
name="보은군" name="보은군 평균"
dataKey="regionVal" dataKey="regionVal"
stroke="#10b981" stroke="#10b981"
fill="#10b981" fill="#10b981"
@@ -441,7 +441,7 @@ export function CategoryEvaluationCard({
/> />
{/* 농가 평균 - Navy Blue (중간) */} {/* 농가 평균 - Navy Blue (중간) */}
<Radar <Radar
name="농가" name="농가 평균"
dataKey="farmVal" dataKey="farmVal"
stroke="#1F3A8F" stroke="#1F3A8F"
fill="#1F3A8F" fill="#1F3A8F"
@@ -480,14 +480,14 @@ export function CategoryEvaluationCard({
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded" style={{ backgroundColor: '#10b981' }}></span> <span className="w-2 h-2 rounded" style={{ backgroundColor: '#10b981' }}></span>
<span className="text-slate-300"></span> <span className="text-slate-300"> </span>
</span> </span>
<span className="text-white font-semibold">{regionVal > 0 ? '+' : ''}{regionVal.toFixed(2)}σ</span> <span className="text-white font-semibold">{regionVal > 0 ? '+' : ''}{regionVal.toFixed(2)}σ</span>
</div> </div>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded" style={{ backgroundColor: '#1F3A8F' }}></span> <span className="w-2 h-2 rounded" style={{ backgroundColor: '#1F3A8F' }}></span>
<span className="text-slate-300"></span> <span className="text-slate-300"> </span>
</span> </span>
<span className="text-white font-semibold">{farmVal > 0 ? '+' : ''}{farmVal.toFixed(2)}σ</span> <span className="text-white font-semibold">{farmVal > 0 ? '+' : ''}{farmVal.toFixed(2)}σ</span>
</div> </div>
@@ -513,11 +513,11 @@ export function CategoryEvaluationCard({
<div className="flex items-center justify-center gap-5 sm:gap-8 py-3 border-t border-border"> <div className="flex items-center justify-center gap-5 sm:gap-8 py-3 border-t border-border">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: '#10b981' }}></div> <div className="w-4 h-4 rounded" style={{ backgroundColor: '#10b981' }}></div>
<span className="text-base text-muted-foreground"></span> <span className="text-base text-muted-foreground"> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: '#1F3A8F' }}></div> <div className="w-4 h-4 rounded" style={{ backgroundColor: '#1F3A8F' }}></div>
<span className="text-base text-muted-foreground"></span> <span className="text-base text-muted-foreground"> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: '#1482B0' }}></div> <div className="w-4 h-4 rounded" style={{ backgroundColor: '#1482B0' }}></div>

View File

@@ -982,28 +982,35 @@ export function NormalDistributionChart({
{/* 농가 vs 개체 거리 화살표 - 농가 배지 바로 아래 */} {/* 농가 vs 개체 거리 화살표 - 농가 배지 바로 아래 */}
{Math.abs(cowVsFarm) > 0.05 && ( {Math.abs(cowVsFarm) > 0.05 && (
<g> <g>
{/* 막대선: 내 개체선 전까지만 */} {/* 화살표 머리 및 막대선 */}
<line
x1={cowVsFarm >= 0 ? farmX + 5 : farmX - 5}
y1={farmArrowY}
x2={cowVsFarm >= 0 ? cowX : cowX}
y2={farmArrowY}
stroke={cowVsFarm >= 0 ? '#22c55e' : '#ef4444'}
strokeWidth={isMobile ? 3 : 4}
/>
{/* 화살표 머리: 내 개체선 넘어서 (뾰족한 화살표) */}
{(() => { {(() => {
const arrowBaseOffset = isMobile ? 10 : 16 const arrowBaseOffset = isMobile ? 10 : 16
const arrowTipOffset = isMobile ? 10 : 16 const arrowTipOffset = isMobile ? 10 : 16
const arrowHeight = isMobile ? 8 : 12 const arrowHeight = isMobile ? 8 : 12
// 화살표 머리 시작점 (막대기 끝점)
const arrowHeadStart = cowVsFarm >= 0
? cowX - arrowBaseOffset - arrowTipOffset
: cowX + arrowBaseOffset + arrowTipOffset
return ( return (
<>
{/* 막대선: 화살표 머리 시작점까지만 */}
<line
x1={cowVsFarm >= 0 ? farmX + 5 : farmX - 5}
y1={farmArrowY}
x2={arrowHeadStart}
y2={farmArrowY}
stroke={cowVsFarm >= 0 ? '#22c55e' : '#ef4444'}
strokeWidth={isMobile ? 3 : 4}
/>
{/* 화살표 머리: 내 개체선에 딱 맞게 */}
<polygon <polygon
points={cowVsFarm >= 0 points={cowVsFarm >= 0
? `${cowX - arrowBaseOffset},${farmArrowY - arrowHeight} ${cowX - arrowBaseOffset},${farmArrowY + arrowHeight} ${cowX + arrowTipOffset},${farmArrowY}` ? `${arrowHeadStart},${farmArrowY - arrowHeight} ${arrowHeadStart},${farmArrowY + arrowHeight} ${cowX},${farmArrowY}`
: `${cowX + arrowBaseOffset},${farmArrowY - arrowHeight} ${cowX + arrowBaseOffset},${farmArrowY + arrowHeight} ${cowX - arrowTipOffset},${farmArrowY}` : `${arrowHeadStart},${farmArrowY - arrowHeight} ${arrowHeadStart},${farmArrowY + arrowHeight} ${cowX},${farmArrowY}`
} }
fill={cowVsFarm >= 0 ? '#22c55e' : '#ef4444'} fill={cowVsFarm >= 0 ? '#22c55e' : '#ef4444'}
/> />
</>
) )
})()} })()}
<rect <rect
@@ -1030,28 +1037,35 @@ export function NormalDistributionChart({
{/* 보은군 vs 개체 거리 화살표 - 보은군 배지 바로 아래 */} {/* 보은군 vs 개체 거리 화살표 - 보은군 배지 바로 아래 */}
{Math.abs(cowVsRegion) > 0.05 && ( {Math.abs(cowVsRegion) > 0.05 && (
<g> <g>
{/* 막대선: 내 개체선 전까지만 */} {/* 화살표 머리 및 막대선 */}
<line
x1={cowVsRegion >= 0 ? regionX + 5 : regionX - 5}
y1={regionArrowY}
x2={cowVsRegion >= 0 ? cowX : cowX}
y2={regionArrowY}
stroke={cowVsRegion >= 0 ? '#22c55e' : '#ef4444'}
strokeWidth={isMobile ? 3 : 4}
/>
{/* 화살표 머리: 내 개체선 넘어서 (뾰족한 화살표) */}
{(() => { {(() => {
const arrowBaseOffset = isMobile ? 10 : 16 const arrowBaseOffset = isMobile ? 10 : 16
const arrowTipOffset = isMobile ? 10 : 16 const arrowTipOffset = isMobile ? 10 : 16
const arrowHeight = isMobile ? 8 : 12 const arrowHeight = isMobile ? 8 : 12
// 화살표 머리 시작점 (막대기 끝점)
const arrowHeadStart = cowVsRegion >= 0
? cowX - arrowBaseOffset - arrowTipOffset
: cowX + arrowBaseOffset + arrowTipOffset
return ( return (
<>
{/* 막대선: 화살표 머리 시작점까지만 */}
<line
x1={cowVsRegion >= 0 ? regionX + 5 : regionX - 5}
y1={regionArrowY}
x2={arrowHeadStart}
y2={regionArrowY}
stroke={cowVsRegion >= 0 ? '#22c55e' : '#ef4444'}
strokeWidth={isMobile ? 3 : 4}
/>
{/* 화살표 머리: 내 개체선에 딱 맞게 */}
<polygon <polygon
points={cowVsRegion >= 0 points={cowVsRegion >= 0
? `${cowX - arrowBaseOffset},${regionArrowY - arrowHeight} ${cowX - arrowBaseOffset},${regionArrowY + arrowHeight} ${cowX + arrowTipOffset},${regionArrowY}` ? `${arrowHeadStart},${regionArrowY - arrowHeight} ${arrowHeadStart},${regionArrowY + arrowHeight} ${cowX},${regionArrowY}`
: `${cowX + arrowBaseOffset},${regionArrowY - arrowHeight} ${cowX + arrowBaseOffset},${regionArrowY + arrowHeight} ${cowX - arrowTipOffset},${regionArrowY}` : `${arrowHeadStart},${regionArrowY - arrowHeight} ${arrowHeadStart},${regionArrowY + arrowHeight} ${cowX},${regionArrowY}`
} }
fill={cowVsRegion >= 0 ? '#22c55e' : '#ef4444'} fill={cowVsRegion >= 0 ? '#22c55e' : '#ef4444'}
/> />
</>
) )
})()} })()}
<rect <rect

View File

@@ -305,14 +305,15 @@ export default function CowOverviewPage() {
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate', '우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
] ]
const traitConditions = Object.entries(filters.traitWeights) // 필터가 활성화되고 형질이 선택되어 있으면 가중치 조건 생성 (대시보드와 동일 로직)
.filter(([, weight]) => weight > 0) const finalConditions = filters.isActive && filters.selectedTraits && filters.selectedTraits.length > 0
.map(([traitNm, weight]) => ({ traitNm, weight })) ? filters.selectedTraits.map(traitNm => ({
traitNm,
const finalConditions = traitConditions.length > 0 weight: (filters.traitWeights as Record<string, number>)[traitNm] || 1
? traitConditions }))
: ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 })) : ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 }))
const indexResult = await genomeApi.getSelectionIndex(cowNo, finalConditions) const indexResult = await genomeApi.getSelectionIndex(cowNo, finalConditions)
setSelectionIndex(indexResult) setSelectionIndex(indexResult)
} catch (compErr) { } catch (compErr) {
@@ -332,7 +333,7 @@ export default function CowOverviewPage() {
} }
fetchData() fetchData()
}, [cowNo, toast, filters.traitWeights]) }, [cowNo, toast, filters.isActive, filters.selectedTraits, filters.traitWeights])
// API 데이터를 화면용으로 변환 // API 데이터를 화면용으로 변환
const GENOMIC_TRAITS = useMemo(() => { const GENOMIC_TRAITS = useMemo(() => {

View File

@@ -56,6 +56,7 @@ interface CowWithGenes extends Cow {
cowShortNo?: string // 개체 요약번호 cowShortNo?: string // 개체 요약번호
cowReproType?: string // 번식 타입 cowReproType?: string // 번식 타입
anlysDt?: string // 분석일자 anlysDt?: string // 분석일자
unavailableReason?: string // 분석불가 사유 (부불일치, 모불일치, 모이력제부재 등)
} }
function MyCowContent() { function MyCowContent() {
@@ -266,7 +267,7 @@ function MyCowContent() {
// response는 { items: RankingResultItem[], total, criteriaType, timestamp } 형식 // response는 { items: RankingResultItem[], total, criteriaType, timestamp } 형식
// items의 각 요소는 { entity, rank, sortValue, grade, details } 형식 // items의 각 요소는 { entity, rank, sortValue, grade, details } 형식
interface RankingItem { interface RankingItem {
entity: Cow & { genes?: Record<string, number>; calvingCount?: number; bcs?: number; inseminationCount?: number; inbreedingPercent?: number; sireKpn?: string; anlysDt?: string }; entity: Cow & { genes?: Record<string, number>; calvingCount?: number; bcs?: number; inseminationCount?: number; inbreedingPercent?: number; sireKpn?: string; anlysDt?: string; unavailableReason?: string };
rank: number; rank: number;
sortValue: number; sortValue: number;
grade: string; grade: string;
@@ -351,6 +352,8 @@ function MyCowContent() {
sireKpn: item.entity.sireKpn ?? null, sireKpn: item.entity.sireKpn ?? null,
// 분석일자 // 분석일자
anlysDt: item.entity.anlysDt ?? null, anlysDt: item.entity.anlysDt ?? null,
// 분석불가 사유
unavailableReason: item.entity.unavailableReason ?? null,
//==================================================================================================================== //====================================================================================================================
// 형질 데이터 (백엔드에서 계산됨, 형질명 → 표준화육종가 매핑) // 형질 데이터 (백엔드에서 계산됨, 형질명 → 표준화육종가 매핑)
// 백엔드 응답: { traitName: string, traitVal: number, traitEbv: number, traitPercentile: number } // 백엔드 응답: { traitName: string, traitVal: number, traitEbv: number, traitPercentile: number }
@@ -982,7 +985,11 @@ function MyCowContent() {
year: '2-digit', year: '2-digit',
month: '2-digit', month: '2-digit',
day: '2-digit' day: '2-digit'
}) : '-'} }) : (
<span className={cow.unavailableReason ? 'text-red-500 font-medium' : 'text-slate-400'}>
{cow.unavailableReason || '미분석'}
</span>
)}
</td> </td>
<td className="cow-table-cell border-r-2 border-r-gray-300 !py-2 !px-0.5"> <td className="cow-table-cell border-r-2 border-r-gray-300 !py-2 !px-0.5">
{(cow.genomeScore !== undefined && cow.genomeScore !== null) ? ( {(cow.genomeScore !== undefined && cow.genomeScore !== null) ? (
@@ -1140,9 +1147,15 @@ function MyCowContent() {
</Badge> </Badge>
</div> </div>
<div className="flex-shrink-0 ml-2"> <div className="flex-shrink-0 ml-2">
{cow.genomeScore !== undefined && cow.genomeScore !== null ? (
<span className="font-bold text-xl text-primary"> <span className="font-bold text-xl text-primary">
{cow.genomeScore !== undefined && cow.genomeScore !== null ? cow.genomeScore.toFixed(2) : '-'} {cow.genomeScore.toFixed(2)}
</span> </span>
) : (
<Badge className="text-[11px] px-1.5 py-0.5 bg-slate-500 text-white border-0 font-medium">
</Badge>
)}
</div> </div>
</div> </div>
@@ -1179,7 +1192,11 @@ function MyCowContent() {
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span className="font-medium"> <span className="font-medium">
{cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : '-'} {cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : (
<span className={cow.unavailableReason ? 'text-red-500' : 'text-slate-400'}>
{cow.unavailableReason || '미분석'}
</span>
)}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -145,10 +145,17 @@ export default function DashboardPage() {
useEffect(() => { useEffect(() => {
const fetchStats = async () => { const fetchStats = async () => {
if (!farmNo) return if (!farmNo) return
// 필터가 활성화되고 형질이 선택되어 있으면 가중치 조건 생성
const traitConditions = filters.isActive && filters.selectedTraits && filters.selectedTraits.length > 0
? filters.selectedTraits.map(traitNm => ({
traitNm,
weight: (filters.traitWeights as Record<string, number>)[traitNm] || 1
}))
: undefined
try { try {
const [statsData, rankingData] = await Promise.all([ const [statsData, rankingData] = await Promise.all([
genomeApi.getDashboardStats(farmNo), genomeApi.getDashboardStats(farmNo),
genomeApi.getFarmRegionRanking(farmNo) genomeApi.getFarmRegionRanking(farmNo, traitConditions)
]) ])
setStats(statsData) setStats(statsData)
setFarmRanking(rankingData) setFarmRanking(rankingData)
@@ -157,7 +164,7 @@ export default function DashboardPage() {
} }
} }
fetchStats() fetchStats()
}, [farmNo]) }, [farmNo, filters.isActive, filters.selectedTraits, filters.traitWeights])
// 연도별 형질 추이 데이터 로드 // 연도별 형질 추이 데이터 로드
useEffect(() => { useEffect(() => {
@@ -268,9 +275,10 @@ export default function DashboardPage() {
// 개별 형질 선택 시 - traitAverages에서 해당 형질 찾기 // 개별 형질 선택 시 - traitAverages에서 해당 형질 찾기
const traitData = stats?.traitAverages?.find(t => t.traitName === distributionBasis) const traitData = stats?.traitAverages?.find(t => t.traitName === distributionBasis)
if (traitData) { if (traitData) {
farmScore = traitData.avgEbv ?? 0 const farmEpd = traitData.avgEpd ?? 0
// 보은군 평균은 0으로 가정 (표준화 육종가 기준) const regionEpd = traitData.regionAvgEpd ?? 0
regionScore = 0 farmScore = farmEpd - regionEpd // 보은군 대비 차이
regionScore = 0 // 보은군 = 기준점 (0)
label = distributionBasis label = distributionBasis
rank = traitData.rank ?? null rank = traitData.rank ?? null
totalFarms = traitData.totalFarms ?? farmRanking.totalFarmsInRegion ?? 0 totalFarms = traitData.totalFarms ?? farmRanking.totalFarmsInRegion ?? 0
@@ -278,28 +286,60 @@ export default function DashboardPage() {
} }
} }
const absMax = Math.max(Math.abs(farmScore), Math.abs(regionScore)) const absMax = Math.max(Math.abs(farmScore), 0.1)
const range = Math.max(2.5, Math.ceil(absMax * 2) / 2 + 0.5) // 데이터 범위에 따라 적절한 X축 범위 계산
// absMax의 1.5배를 범위로 하고, 깔끔한 숫자로 반올림
const rawRange = absMax * 1.5
let range: number
let step: number
// 정규분포 비율 (0.5σ 단위) if (rawRange <= 3) {
const sigmaPercentMap: Record<string, number> = { // 표준화 육종가 스케일 (전체 선발지수)
'-3.0': 0.10, '-2.5': 0.52, '-2.0': 1.65, '-1.5': 4.41, range = Math.max(2.5, Math.ceil(rawRange * 2) / 2)
'-1.0': 9.18, '-0.5': 15.00, '0.0': 19.14, '0.5': 19.14, step = 0.5
'1.0': 15.00, '1.5': 9.18, '2.0': 4.41, '2.5': 1.65, '3.0': 0.52, } else if (rawRange <= 10) {
range = Math.ceil(rawRange / 2) * 2 // 2 단위로 반올림
step = range / 5
} else if (rawRange <= 50) {
range = Math.ceil(rawRange / 10) * 10 // 10 단위로 반올림
step = range / 5
} else {
range = Math.ceil(rawRange / 20) * 20 // 20 단위로 반올림
step = range / 5
} }
// 정규분포 비율 계산 (구간 개수에 맞춤)
const numBins = Math.round((range * 2) / step)
const bins = [] const bins = []
for (let sigma = -range; sigma < range; sigma += 0.5) {
const key = sigma.toFixed(1) // 정규분포 PDF 기반으로 각 구간의 비율 계산
const percent = sigmaPercentMap[key] ?? 0.1 const normalPDF = (x: number, sigma: number = range / 3) => {
return Math.exp(-0.5 * Math.pow(x / sigma, 2)) / (sigma * Math.sqrt(2 * Math.PI))
}
const total = farmRanking.totalFarmsInRegion || 50 const total = farmRanking.totalFarmsInRegion || 50
let totalPercent = 0
const tempBins = []
for (let i = 0; i < numBins; i++) {
const min = -range + i * step
const max = min + step
const midPoint = (min + max) / 2
const percent = normalPDF(midPoint) * step * 100
totalPercent += percent
tempBins.push({ min, max, midPoint, percent })
}
// 비율 정규화
for (const bin of tempBins) {
const normalizedPercent = (bin.percent / totalPercent) * 100
bins.push({ bins.push({
range: `${sigma}σ~${sigma + 0.5}σ`, range: `${bin.min.toFixed(1)}~${bin.max.toFixed(1)}`,
min: sigma, min: bin.min,
max: sigma + 0.5, max: bin.max,
midPoint: sigma + 0.25, midPoint: bin.midPoint,
count: Math.round(total * percent / 100), count: Math.round(total * normalizedPercent / 100),
percent percent: normalizedPercent
}) })
} }
@@ -636,22 +676,27 @@ export default function DashboardPage() {
? (actualLineLength < minLineLength ? farmX - minLineLength : regionX + 5) ? (actualLineLength < minLineLength ? farmX - minLineLength : regionX + 5)
: (actualLineLength < minLineLength ? farmX + minLineLength : regionX - 5) : (actualLineLength < minLineLength ? farmX + minLineLength : regionX - 5)
// 화살표 머리 시작점 (막대기 끝점)
const arrowHeadStart = diff >= 0
? farmX - arrowBaseOffset - arrowTipOffset
: farmX + arrowBaseOffset + arrowTipOffset
return ( return (
<g> <g>
{/* 막대선: 최소 길이 보장 */} {/* 막대선: 화살표 머리 시작점까지만 */}
<line <line
x1={lineStartX} x1={lineStartX}
y1={arrowY} y1={arrowY}
x2={farmX} x2={arrowHeadStart}
y2={arrowY} y2={arrowY}
stroke={color} stroke={color}
strokeWidth={isMobileView ? 3 : 4} strokeWidth={isMobileView ? 3 : 4}
/> />
{/* 화살표 머리 (농가선 넘어서, 뾰족한 화살표) */} {/* 화살표 머리 (농가선에 딱 맞게) */}
<polygon <polygon
points={diff >= 0 points={diff >= 0
? `${farmX - arrowBaseOffset},${arrowY - arrowHeight} ${farmX - arrowBaseOffset},${arrowY + arrowHeight} ${farmX + arrowTipOffset},${arrowY}` ? `${arrowHeadStart},${arrowY - arrowHeight} ${arrowHeadStart},${arrowY + arrowHeight} ${farmX},${arrowY}`
: `${farmX + arrowBaseOffset},${arrowY - arrowHeight} ${farmX + arrowBaseOffset},${arrowY + arrowHeight} ${farmX - arrowTipOffset},${arrowY}` : `${arrowHeadStart},${arrowY - arrowHeight} ${arrowHeadStart},${arrowY + arrowHeight} ${farmX},${arrowY}`
} }
fill={color} fill={color}
/> />
@@ -1169,12 +1214,13 @@ export default function DashboardPage() {
{/* 우측: 상세 바 차트 - 68% */} {/* 우측: 상세 바 차트 - 68% */}
<div className="w-full lg:flex-1 space-y-3 sm:space-y-3"> <div className="w-full lg:flex-1 space-y-3 sm:space-y-3">
{categoryData.map(({ category, avgEpd, avgPercentile, traitCount }) => { {categoryData.map(({ category, avgEpd, avgPercentile, traitCount }, index) => {
const isPositive = avgEpd >= 0 const isPositive = avgEpd >= 0
// 바 너비: 육종가 스케일에 맞게 계산 // 바 너비: 육종가 스케일에 맞게 계산
const barWidth = Math.min(Math.abs(avgEpd) / maxAbs * 45, 48) const barWidth = Math.min(Math.abs(avgEpd) / maxAbs * 45, 48)
const isFirst = index === 0
return ( return (
<div key={category} className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2"> <div key={category} className={`flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 ${isFirst ? 'sm:mt-4' : ''}`}>
{/* 모바일: 카테고리명 + 값을 한 줄에 */} {/* 모바일: 카테고리명 + 값을 한 줄에 */}
<div className="flex items-center justify-between sm:hidden"> <div className="flex items-center justify-between sm:hidden">
<span className="text-sm font-bold text-slate-700">{category}</span> <span className="text-sm font-bold text-slate-700">{category}</span>
@@ -1201,7 +1247,13 @@ export default function DashboardPage() {
</div> </div>
{/* 데스크톱: 한 줄 레이아웃 */} {/* 데스크톱: 한 줄 레이아웃 */}
<span className="hidden sm:block w-12 text-base font-bold text-slate-700 flex-shrink-0">{category}</span> <span className="hidden sm:block w-12 text-base font-bold text-slate-700 flex-shrink-0">{category}</span>
<div className="hidden sm:block w-[70%] h-10 bg-slate-100 rounded-full relative overflow-hidden"> <div className={`hidden sm:block w-[70%] h-10 relative ${isFirst ? 'mt-5' : ''}`}>
{/* 보은군 평균 라벨 (첫 번째 바 위에만 표시) */}
{isFirst && (
<span className="absolute left-1/2 -translate-x-1/2 -top-5 text-[11px] text-slate-500 font-medium whitespace-nowrap"> </span>
)}
{/* 바 차트 */}
<div className="absolute inset-0 bg-slate-100 rounded-full overflow-hidden">
<div className="absolute left-1/2 top-0 bottom-0 w-0.5 bg-slate-400 z-10"></div> <div className="absolute left-1/2 top-0 bottom-0 w-0.5 bg-slate-400 z-10"></div>
<div <div
className={`absolute top-1 bottom-1 rounded-full transition-all duration-500 ${isPositive ? 'bg-[#1F3A8F]' : 'bg-red-400'}`} className={`absolute top-1 bottom-1 rounded-full transition-all duration-500 ${isPositive ? 'bg-[#1F3A8F]' : 'bg-red-400'}`}
@@ -1211,6 +1263,7 @@ export default function DashboardPage() {
}} }}
/> />
</div> </div>
</div>
<span className={`hidden sm:block w-20 text-right text-lg font-bold flex-shrink-0 ml-3 ${isPositive ? 'text-[#1F3A8F]' : 'text-red-500'}`}> <span className={`hidden sm:block w-20 text-right text-lg font-bold flex-shrink-0 ml-3 ${isPositive ? 'text-[#1F3A8F]' : 'text-red-500'}`}>
{isPositive ? '+' : ''}{avgEpd.toFixed(2)} {isPositive ? '+' : ''}{avgEpd.toFixed(2)}
</span> </span>

View File

@@ -0,0 +1,237 @@
'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

@@ -0,0 +1,237 @@
'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

@@ -0,0 +1,549 @@
'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

@@ -0,0 +1,455 @@
'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

@@ -0,0 +1,331 @@
'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>
);
}

View File

@@ -37,7 +37,7 @@ export default function FindIdPage() {
/> />
</div> </div>
<div className="flex flex-1 items-center justify-center"> <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="w-full max-w-[320px] lg:max-w-sm">
<FindIdForm /> <FindIdForm />
</div> </div>

View File

@@ -31,7 +31,7 @@ export default function FindPwPage() {
/> />
</div> </div>
<div className="flex flex-1 items-center justify-center"> <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="w-full max-w-[320px] lg:max-w-sm">
<FindPwForm /> <FindPwForm />
</div> </div>

View File

@@ -2,24 +2,43 @@
import { LoginForm } from "@/components/auth/login-form"; import { LoginForm } from "@/components/auth/login-form";
import { useAuthStore } from '@/store/auth-store'; import { useAuthStore } from '@/store/auth-store';
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState, useEffect } from "react";
import Image from "next/image"; import Image from "next/image";
const SAVED_USER_ID_KEY = 'savedUserId';
// 로그인 페이지 컴포넌트 // 로그인 페이지 컴포넌트
export default function LoginPage() { export default function LoginPage() {
const [userId, setUserId] = useState('') const [userId, setUserId] = useState('')
const [userPassword, setUserPassword] = useState('') const [userPassword, setUserPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [saveId, setSaveId] = useState(false)
const router = useRouter() const router = useRouter()
const { login } = useAuthStore(); const { login } = useAuthStore();
// 저장된 아이디 불러오기
useEffect(() => {
const savedUserId = localStorage.getItem(SAVED_USER_ID_KEY)
if (savedUserId) {
setUserId(savedUserId)
setSaveId(true)
}
}, [])
// 로그인 처리 함수 // 로그인 처리 함수
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
setIsLoading(true) setIsLoading(true)
// 아이디 저장 처리
if (saveId) {
localStorage.setItem(SAVED_USER_ID_KEY, userId)
} else {
localStorage.removeItem(SAVED_USER_ID_KEY)
}
try { try {
await login({ await login({
userId: userId, userId: userId,
@@ -72,7 +91,7 @@ export default function LoginPage() {
/> />
</div> </div>
<div className="flex flex-1 items-center justify-start lg:pl-24"> <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="w-full max-w-[320px] lg:max-w-sm">
<LoginForm <LoginForm
onSubmit={handleLogin} onSubmit={handleLogin}
@@ -82,6 +101,8 @@ export default function LoginPage() {
setUserPassword={setUserPassword} setUserPassword={setUserPassword}
error={error} error={error}
isLoading={isLoading} isLoading={isLoading}
saveId={saveId}
setSaveId={setSaveId}
/> />
</div> </div>
</div> </div>

View File

@@ -30,6 +30,7 @@ export default function SignupPage() {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [emailCheckStatus, setEmailCheckStatus] = useState<'idle' | 'checking' | 'available' | 'unavailable'>('idle') const [emailCheckStatus, setEmailCheckStatus] = useState<'idle' | 'checking' | 'available' | 'unavailable'>('idle')
const [emailCheckMessage, setEmailCheckMessage] = useState('') const [emailCheckMessage, setEmailCheckMessage] = useState('')
const [currentStep, setCurrentStep] = useState(1)
const router = useRouter() const router = useRouter()
const { signup } = useAuthStore() const { signup } = useAuthStore()
@@ -233,7 +234,7 @@ export default function SignupPage() {
/> />
</div> </div>
<div className="flex flex-1 items-center justify-center"> <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="w-full max-w-[320px] lg:max-w-sm">
<SignupForm <SignupForm
onSubmit={handleSignup} onSubmit={handleSignup}
@@ -251,6 +252,8 @@ export default function SignupPage() {
isVerifyingCode={isVerifyingCode} isVerifyingCode={isVerifyingCode}
emailCheckStatus={emailCheckStatus} emailCheckStatus={emailCheckStatus}
emailCheckMessage={emailCheckMessage} emailCheckMessage={emailCheckMessage}
currentStep={currentStep}
setCurrentStep={setCurrentStep}
/> />
</div> </div>
</div> </div>

View File

@@ -130,7 +130,7 @@ export function FindIdForm({
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="example@email.com" placeholder="이메일 주소를 입력해주세요"
value={userEmail} value={userEmail}
onChange={(e) => setUserEmail(e.target.value)} onChange={(e) => setUserEmail(e.target.value)}
required required

View File

@@ -86,12 +86,12 @@ export function FindIdForm({
} }
return ( return (
<form className={cn("flex flex-col", className)} {...props}> <form className={cn("flex flex-col gap-4", className)} {...props}>
<FieldGroup> <FieldGroup>
<div className="flex flex-col items-center gap-1 text-center"> <div className="flex flex-col items-center gap-1 text-center mb-2">
<h1 className="text-2xl font-bold"> </h1> <h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground text-sm text-balance"> <p className="text-muted-foreground text-sm text-balance">
{step === "email" && "가입 시 등록한 이름과 이메일을 입력해주세요"} {step === "email" && "가입 시 등록한 정보를 입력해주세요"}
{step === "verify" && "이메일로 전송된 인증번호를 입력해주세요"} {step === "verify" && "이메일로 전송된 인증번호를 입력해주세요"}
{step === "result" && "아이디 찾기가 완료되었습니다"} {step === "result" && "아이디 찾기가 완료되었습니다"}
</p> </p>
@@ -104,30 +104,34 @@ export function FindIdForm({
<Input <Input
id="name" id="name"
type="text" type="text"
placeholder="홍길동" placeholder="이름을 입력하세요"
value={userName} value={userName}
onChange={(e) => setUserName(e.target.value)} onChange={(e) => setUserName(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
className="h-11"
/> />
</Field> </Field>
<Field> <Field>
<div className="flex items-center justify-between">
<FieldLabel htmlFor="email"></FieldLabel> <FieldLabel htmlFor="email"></FieldLabel>
<a href="/findpw" className="text-xs text-primary hover:underline">
</a>
</div>
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="example@email.com" placeholder="이메일을 입력하세요"
value={userEmail} value={userEmail}
onChange={(e) => setUserEmail(e.target.value)} onChange={(e) => setUserEmail(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
className="h-11"
/> />
<FieldDescription>
</FieldDescription>
</Field> </Field>
<Field> <Field>
<Button type="submit" onClick={handleSendCode} disabled={isLoading}> <Button type="submit" onClick={handleSendCode} disabled={isLoading} className="w-full h-11">
{isLoading ? "발송 중..." : "인증번호 발송"} {isLoading ? "발송 중..." : "인증번호 발송"}
</Button> </Button>
</Field> </Field>
@@ -143,6 +147,7 @@ export function FindIdForm({
type="email" type="email"
value={userEmail} value={userEmail}
disabled disabled
className="h-11"
/> />
</Field> </Field>
<Field> <Field>
@@ -156,18 +161,19 @@ export function FindIdForm({
maxLength={6} maxLength={6}
required required
disabled={isLoading} disabled={isLoading}
className="h-11"
/> />
<FieldDescription> <FieldDescription>
{timer > 0 ? "남은 시간: " + formatTime(timer) : "인증번호가 만료되었습니다"} {timer > 0 ? "남은 시간: " + formatTime(timer) : "인증번호가 만료되었습니다"}
</FieldDescription> </FieldDescription>
</Field> </Field>
<Field> <Field>
<Button type="submit" onClick={handleVerifyCode} disabled={isLoading || timer === 0}> <Button type="submit" onClick={handleVerifyCode} disabled={isLoading || timer === 0} className="w-full h-11">
{isLoading ? "확인 중..." : "인증번호 확인"} {isLoading ? "확인 중..." : "인증번호 확인"}
</Button> </Button>
</Field> </Field>
<Field> <Field>
<Button type="button" variant="outline" onClick={() => setStep("email")} disabled={isLoading}> <Button type="button" variant="outline" onClick={() => setStep("email")} disabled={isLoading} className="w-full h-11">
</Button> </Button>
</Field> </Field>
@@ -184,29 +190,38 @@ export function FindIdForm({
</div> </div>
</Field> </Field>
<Field> <Field>
<Button type="button" onClick={() => router.push("/login")}> <Button type="button" onClick={() => router.push("/login")} className="w-full h-11">
</Button> </Button>
</Field> </Field>
<Field> <Field>
<Button type="button" variant="outline" onClick={() => router.push("/findpw")}> <Button type="button" variant="outline" onClick={() => router.push("/findpw")} className="w-full h-11">
</Button> </Button>
</Field> </Field>
</> </>
)} )}
<Field className="-mt-5"> {step === "email" && (
<Button variant="outline" type="button" onClick={() => router.push("/login")} className="w-full border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300"> <>
<FieldSeparator></FieldSeparator>
<Field>
<Button
variant="outline"
type="button"
onClick={() => router.push("/login")}
className="w-full h-11 border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300"
>
</Button> </Button>
</Field> </Field>
<FieldSeparator> ?</FieldSeparator> <div className="text-center">
<Field> <a href="/signup" className="text-sm text-gray-500 hover:text-primary">
<Button variant="outline" type="button" onClick={() => router.push("/signup")} className="w-full border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300"> ?
</a>
</Button> </div>
</Field> </>
)}
</FieldGroup> </FieldGroup>
</form> </form>
) )

View File

@@ -46,9 +46,8 @@ export function FindPwForm({
const result = await authApi.sendResetPasswordCode(userId, userEmail) const result = await authApi.sendResetPasswordCode(userId, userEmail)
toast.success(result.message) toast.success(result.message)
setStep("verify") setStep("verify")
setTimer(result.expiresIn) // 이미 초 단위로 전달됨 (180초 = 3분) setTimer(result.expiresIn)
// 타이머 시작
const interval = setInterval(() => { const interval = setInterval(() => {
setTimer((prev) => { setTimer((prev) => {
if (prev <= 1) { if (prev <= 1) {
@@ -117,12 +116,7 @@ export function FindPwForm({
} }
} }
// 로그인 페이지로 이동 // 타이머 포맷
const handleGoToLogin = () => {
router.push("/login")
}
// 타이머 포맷 (mm:ss)
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60) const m = Math.floor(seconds / 60)
const s = seconds % 60 const s = seconds % 60
@@ -130,12 +124,12 @@ export function FindPwForm({
} }
return ( return (
<form className={cn("flex flex-col", className)} {...props}> <form className={cn("flex flex-col gap-4", className)} {...props}>
<FieldGroup> <FieldGroup>
<div className="flex flex-col items-center gap-1 text-center"> <div className="flex flex-col items-center gap-1 text-center mb-2">
<h1 className="text-2xl font-bold"> </h1> <h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground text-sm text-balance"> <p className="text-muted-foreground text-sm text-balance">
{step === "info" && "아이디와 이메일을 입력해주세요"} {step === "info" && "등록된 정보를 입력해주세요"}
{step === "verify" && "이메일로 전송된 인증번호를 입력해주세요"} {step === "verify" && "이메일로 전송된 인증번호를 입력해주세요"}
{step === "reset" && "새로운 비밀번호를 설정해주세요"} {step === "reset" && "새로운 비밀번호를 설정해주세요"}
{step === "complete" && "비밀번호 재설정이 완료되었습니다"} {step === "complete" && "비밀번호 재설정이 완료되었습니다"}
@@ -154,25 +148,29 @@ export function FindPwForm({
onChange={(e) => setUserId(e.target.value)} onChange={(e) => setUserId(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
className="h-11"
/> />
</Field> </Field>
<Field> <Field>
<div className="flex items-center justify-between">
<FieldLabel htmlFor="email"></FieldLabel> <FieldLabel htmlFor="email"></FieldLabel>
<a href="/findid" className="text-xs text-primary hover:underline">
</a>
</div>
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="example@email.com" placeholder="이메일을 입력하세요"
value={userEmail} value={userEmail}
onChange={(e) => setUserEmail(e.target.value)} onChange={(e) => setUserEmail(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
className="h-11"
/> />
<FieldDescription>
</FieldDescription>
</Field> </Field>
<Field> <Field>
<Button type="submit" onClick={handleSendCode} disabled={isLoading}> <Button type="submit" onClick={handleSendCode} disabled={isLoading} className="w-full h-11">
{isLoading ? "발송 중..." : "인증번호 발송"} {isLoading ? "발송 중..." : "인증번호 발송"}
</Button> </Button>
</Field> </Field>
@@ -183,21 +181,11 @@ export function FindPwForm({
<> <>
<Field> <Field>
<FieldLabel htmlFor="userId"></FieldLabel> <FieldLabel htmlFor="userId"></FieldLabel>
<Input <Input id="userId" type="text" value={userId} disabled className="h-11" />
id="userId"
type="text"
value={userId}
disabled
/>
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="email"></FieldLabel> <FieldLabel htmlFor="email"></FieldLabel>
<Input <Input id="email" type="email" value={userEmail} disabled className="h-11" />
id="email"
type="email"
value={userEmail}
disabled
/>
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="code"></FieldLabel> <FieldLabel htmlFor="code"></FieldLabel>
@@ -210,18 +198,19 @@ export function FindPwForm({
maxLength={6} maxLength={6}
required required
disabled={isLoading} disabled={isLoading}
className="h-11"
/> />
<FieldDescription> <FieldDescription>
{timer > 0 ? `남은 시간: ${formatTime(timer)}` : "인증번호가 만료되었습니다"} {timer > 0 ? `남은 시간: ${formatTime(timer)}` : "인증번호가 만료되었습니다"}
</FieldDescription> </FieldDescription>
</Field> </Field>
<Field> <Field>
<Button type="submit" onClick={handleVerifyCode} disabled={isLoading || timer === 0}> <Button type="submit" onClick={handleVerifyCode} disabled={isLoading || timer === 0} className="w-full h-11">
{isLoading ? "확인 중..." : "인증번호 확인"} {isLoading ? "확인 중..." : "인증번호 확인"}
</Button> </Button>
</Field> </Field>
<Field> <Field>
<Button type="button" variant="outline" onClick={() => setStep("info")} disabled={isLoading}> <Button type="button" variant="outline" onClick={() => setStep("info")} disabled={isLoading} className="w-full h-11">
</Button> </Button>
</Field> </Field>
@@ -241,18 +230,17 @@ export function FindPwForm({
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
className="h-11 pr-10"
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2" className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
tabIndex={-1}
> >
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button> </button>
</div> </div>
<FieldDescription>
8
</FieldDescription>
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="confirmPassword"> </FieldLabel> <FieldLabel htmlFor="confirmPassword"> </FieldLabel>
@@ -265,11 +253,13 @@ export function FindPwForm({
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
required required
disabled={isLoading} disabled={isLoading}
className="h-11 pr-10"
/> />
<button <button
type="button" type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)} onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2" className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
tabIndex={-1}
> >
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button> </button>
@@ -281,7 +271,7 @@ export function FindPwForm({
)} )}
</Field> </Field>
<Field> <Field>
<Button type="submit" onClick={handleResetPassword} disabled={isLoading}> <Button type="submit" onClick={handleResetPassword} disabled={isLoading} className="w-full h-11">
{isLoading ? "변경 중..." : "비밀번호 변경"} {isLoading ? "변경 중..." : "비밀번호 변경"}
</Button> </Button>
</Field> </Field>
@@ -299,24 +289,33 @@ export function FindPwForm({
</div> </div>
</Field> </Field>
<Field> <Field>
<Button type="button" onClick={handleGoToLogin}> <Button type="button" onClick={() => router.push("/login")} className="w-full h-11">
</Button> </Button>
</Field> </Field>
</> </>
)} )}
<Field className="-mt-5"> {step === "info" && (
<Button variant="outline" type="button" onClick={() => router.push("/login")} className="w-full border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300"> <>
<FieldSeparator></FieldSeparator>
<Field>
<Button
variant="outline"
type="button"
onClick={() => router.push("/login")}
className="w-full h-11 border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300"
>
</Button> </Button>
</Field> </Field>
<FieldSeparator> ?</FieldSeparator> <div className="text-center">
<Field> <a href="/signup" className="text-sm text-gray-500 hover:text-primary">
<Button variant="outline" type="button" onClick={() => router.push("/signup")} className="w-full border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300"> ?
</a>
</Button> </div>
</Field> </>
)}
</FieldGroup> </FieldGroup>
</form> </form>
) )

View File

@@ -2,14 +2,14 @@ import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Field, Field,
FieldDescription,
FieldGroup, FieldGroup,
FieldLabel, FieldLabel,
FieldSeparator, FieldSeparator,
} from "@/components/ui/field" } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react"; import { Loader2, Eye, EyeOff } from "lucide-react";
import { useState } from "react";
export function LoginForm({ export function LoginForm({
className, className,
@@ -20,6 +20,8 @@ export function LoginForm({
setUserPassword, setUserPassword,
error, error,
isLoading, isLoading,
saveId,
setSaveId,
...props ...props
}: React.ComponentProps<"form"> & { }: React.ComponentProps<"form"> & {
onSubmit: (e: React.FormEvent) => void; onSubmit: (e: React.FormEvent) => void;
@@ -29,15 +31,17 @@ export function LoginForm({
setUserPassword: (value: string) => void; setUserPassword: (value: string) => void;
error?: string; error?: string;
isLoading?: boolean; isLoading?: boolean;
saveId: boolean;
setSaveId: (value: boolean) => void;
}) { }) {
const router = useRouter(); const router = useRouter();
const [showPassword, setShowPassword] = useState(false);
return ( return (
<form className={cn("flex flex-col gap-4", className)} onSubmit={onSubmit} {...props}> <form className={cn("flex flex-col gap-4", className)} onSubmit={onSubmit} {...props}>
<FieldGroup> <FieldGroup>
<div className="flex flex-col items-center gap-1 text-center mb-2"> <div className="flex flex-col items-center gap-1 text-center mb-2">
<h1 className="text-xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground text-sm text-balance"> <p className="text-muted-foreground text-sm text-balance">
</p> </p>
@@ -51,23 +55,47 @@ export function LoginForm({
onChange={(e) => setUserId(e.target.value)} onChange={(e) => setUserId(e.target.value)}
placeholder="아이디를 입력하세요" placeholder="아이디를 입력하세요"
disabled={isLoading} disabled={isLoading}
className="h-11"
required required
/> />
</Field> </Field>
<Field> <Field>
<div className="flex items-center"> <div className="flex items-center justify-between">
<FieldLabel htmlFor="password"></FieldLabel> <FieldLabel htmlFor="password"></FieldLabel>
<a href="/findpw" className="text-xs text-primary hover:underline">
</a>
</div> </div>
<div className="relative">
<Input <Input
id="password" id="password"
type="password" type={showPassword ? "text" : "password"}
value={userPassword} value={userPassword}
onChange={(e) => setUserPassword(e.target.value)} onChange={(e) => setUserPassword(e.target.value)}
placeholder="비밀번호를 입력하세요" placeholder="비밀번호를 입력하세요"
disabled={isLoading} disabled={isLoading}
className="h-11 pr-10"
required required
/> />
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</Field> </Field>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={saveId}
onChange={(e) => setSaveId(e.target.checked)}
className="rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-sm text-gray-600"> </span>
</label>
{error && ( {error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-600"> <div className="rounded-md bg-red-50 p-3 text-sm text-red-600">
{error} {error}
@@ -76,36 +104,30 @@ export function LoginForm({
<Field> <Field>
<Button <Button
type="submit" type="submit"
className="w-full bg-primary hover:bg-primary/90 text-white shadow-lg hover:shadow-xl transition-all duration-300" className="w-full h-11 bg-primary hover:bg-primary/90 text-white shadow-lg hover:shadow-xl transition-all duration-300"
disabled={isLoading} disabled={isLoading}
> >
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isLoading ? '로그인 중...' : '로그인'} {isLoading ? '로그인 중...' : '로그인'}
</Button> </Button>
</Field> </Field>
<FieldSeparator> ?</FieldSeparator> <FieldSeparator></FieldSeparator>
<Field> <Field>
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
onClick={() => router.push('/signup')} onClick={() => router.push('/signup')}
disabled={isLoading} disabled={isLoading}
className="w-full border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300" className="w-full h-11 border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300"
> >
</Button> </Button>
<FieldDescription className="text-center mt-2">
<a href="/findid" className="text-sm hover:underline underline-offset-4">
</a>
{" "}|{" "}
<a
href="/findpw"
className="text-sm hover:underline underline-offset-4">
</a>
</FieldDescription>
</Field> </Field>
<div className="text-center">
<a href="/findid" className="text-sm text-gray-500 hover:text-primary">
?
</a>
</div>
</FieldGroup> </FieldGroup>
</form> </form>
) )

View File

@@ -5,6 +5,7 @@ import {
FieldDescription, FieldDescription,
FieldGroup, FieldGroup,
FieldLabel, FieldLabel,
FieldSeparator,
} from "@/components/ui/field" } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import {
@@ -15,9 +16,9 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Loader2, CheckCircle2, XCircle } from "lucide-react" import { Loader2, CheckCircle2, XCircle, ChevronLeft, ChevronRight, ChevronDown, Eye, EyeOff } from "lucide-react"
import { SignupFormData } from "@/types/auth.types" import { SignupFormData } from "@/types/auth.types"
import { Dispatch, SetStateAction } from "react" import { Dispatch, SetStateAction, useState } from "react"
interface SignupFormProps extends React.ComponentProps<"form"> { interface SignupFormProps extends React.ComponentProps<"form"> {
onSubmit: (e: React.FormEvent) => void; onSubmit: (e: React.FormEvent) => void;
@@ -35,8 +36,12 @@ interface SignupFormProps extends React.ComponentProps<"form"> {
isVerifyingCode: boolean; isVerifyingCode: boolean;
emailCheckStatus: 'idle' | 'checking' | 'available' | 'unavailable'; emailCheckStatus: 'idle' | 'checking' | 'available' | 'unavailable';
emailCheckMessage: string; emailCheckMessage: string;
currentStep: number;
setCurrentStep: Dispatch<SetStateAction<number>>;
} }
const TOTAL_STEPS = 3;
export function SignupForm({ export function SignupForm({
className, className,
onSubmit, onSubmit,
@@ -54,9 +59,13 @@ export function SignupForm({
isVerifyingCode, isVerifyingCode,
emailCheckStatus, emailCheckStatus,
emailCheckMessage, emailCheckMessage,
currentStep,
setCurrentStep,
...props ...props
}: SignupFormProps) { }: SignupFormProps) {
const router = useRouter(); const router = useRouter();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const handleChange = (field: keyof SignupFormData, value: string) => { const handleChange = (field: keyof SignupFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
@@ -72,16 +81,86 @@ export function SignupForm({
'직접입력', '직접입력',
]; ];
const canGoNext = () => {
switch (currentStep) {
case 1:
return formData.userSe && formData.userId.length >= 4 && formData.userName.length >= 2;
case 2:
return isEmailVerified;
case 3:
return formData.userPhone && formData.userPassword.length >= 8 && formData.userPassword === formData.confirmPassword;
default:
return false;
}
};
const handleNext = () => {
if (currentStep < TOTAL_STEPS && canGoNext()) {
setCurrentStep(prev => prev + 1);
}
};
const handlePrev = () => {
if (currentStep > 1) {
setCurrentStep(prev => prev - 1);
}
};
const getStepTitle = () => {
switch (currentStep) {
case 1:
return "기본 정보";
case 2:
return "이메일 인증";
case 3:
return "추가 정보";
default:
return "";
}
};
return ( return (
<form className={cn("flex flex-col gap-6", className)} onSubmit={onSubmit} {...props}> <form className={cn("flex flex-col gap-4", className)} onSubmit={onSubmit} {...props}>
<FieldGroup> <FieldGroup>
<div className="flex flex-col items-center gap-1 text-center"> {/* 헤더 */}
<div className="flex flex-col items-center gap-1 text-center mb-2">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground text-sm text-balance"> <p className="text-muted-foreground text-sm">
{getStepTitle()}
</p> </p>
</div> </div>
{/* 진행률 표시 */}
<div className="flex items-center justify-center gap-2 py-2">
{[1, 2, 3].map((step) => (
<div key={step} className="flex items-center">
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors",
currentStep === step
? "bg-primary text-primary-foreground"
: currentStep > step
? "bg-primary/20 text-primary"
: "bg-gray-300 text-gray-500"
)}
>
{currentStep > step ? <CheckCircle2 className="w-4 h-4" /> : step}
</div>
{step < 3 && (
<div
className={cn(
"w-8 h-0.5 mx-1",
currentStep > step ? "bg-primary/20" : "bg-gray-300"
)}
/>
)}
</div>
))}
</div>
{/* Step 1: 기본 정보 */}
{currentStep === 1 && (
<>
<Field> <Field>
<FieldLabel htmlFor="userSe"> *</FieldLabel> <FieldLabel htmlFor="userSe"> *</FieldLabel>
<Select <Select
@@ -90,7 +169,7 @@ export function SignupForm({
disabled={isLoading} disabled={isLoading}
required required
> >
<SelectTrigger> <SelectTrigger className="h-11 w-full">
<SelectValue placeholder="회원 유형을 선택하세요" /> <SelectValue placeholder="회원 유형을 선택하세요" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -99,7 +178,6 @@ export function SignupForm({
<SelectItem value="ORGAN"></SelectItem> <SelectItem value="ORGAN"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FieldDescription> .</FieldDescription>
</Field> </Field>
<Field> <Field>
@@ -112,8 +190,8 @@ export function SignupForm({
placeholder="아이디를 입력하세요 (4자 이상)" placeholder="아이디를 입력하세요 (4자 이상)"
disabled={isLoading} disabled={isLoading}
required required
className="h-11"
/> />
<FieldDescription>4 .</FieldDescription>
</Field> </Field>
<Field> <Field>
@@ -126,12 +204,17 @@ export function SignupForm({
placeholder="이름을 입력하세요 (2자 이상)" placeholder="이름을 입력하세요 (2자 이상)"
disabled={isLoading} disabled={isLoading}
required required
className="h-11"
/> />
</Field> </Field>
</>
)}
{/* Step 2: 이메일 인증 */}
{currentStep === 2 && (
<>
<Field> <Field>
<FieldLabel htmlFor="userEmail"> *</FieldLabel> <FieldLabel htmlFor="userEmail"> *</FieldLabel>
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Input <Input
id="emailId" id="emailId"
@@ -140,7 +223,7 @@ export function SignupForm({
onChange={(e) => { onChange={(e) => {
const newEmailId = e.target.value; const newEmailId = e.target.value;
setFormData((prev) => { setFormData((prev) => {
const domain = prev.emailDomain === '직접입력' ? '' : prev.emailDomain; const domain = prev.emailDomain === '직접입력' ? prev.customDomain : prev.emailDomain;
return { return {
...prev, ...prev,
emailId: newEmailId, emailId: newEmailId,
@@ -148,18 +231,18 @@ export function SignupForm({
}; };
}); });
}} }}
placeholder="이메일 아이디" placeholder="이메일"
disabled={isLoading || isEmailVerified} disabled={isLoading || isEmailVerified}
required required
className="flex-1" className="flex-1 h-11"
/> />
<span className="text-muted-foreground">@</span> <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">
{formData.emailDomain === '직접입력' ? ( {formData.emailDomain === '직접입력' ? (
<div className="flex flex-1">
<Input <Input
id="customDomain" id="customDomain"
type="text" type="text"
placeholder="도메인 주소" placeholder="도메인 입력"
value={formData.customDomain || ''} value={formData.customDomain || ''}
onChange={(e) => { onChange={(e) => {
const newDomain = e.target.value; const newDomain = e.target.value;
@@ -171,33 +254,13 @@ export function SignupForm({
}} }}
disabled={isLoading || isEmailVerified} disabled={isLoading || isEmailVerified}
required required
className="flex-1 rounded-r-none border-r-0 h-9 !text-sm px-3 py-2" className="flex-1 h-full border-0 focus-visible:ring-0 focus-visible:ring-offset-0 rounded-r-none"
/> />
<Select
value=""
onValueChange={(value) => {
setFormData((prev) => ({
...prev,
emailDomain: value,
customDomain: '',
userEmail: `${prev.emailId}@${value}`,
}));
}}
disabled={isLoading || isEmailVerified}
>
<SelectTrigger className="w-[50px] rounded-l-none -ml-px px-2 h-9 [&>svg]:size-4">
<SelectValue />
</SelectTrigger>
<SelectContent>
{emailDomains.map((domain) => (
<SelectItem key={domain} value={domain}>
{domain}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : ( ) : (
<span className="flex-1 px-3 text-sm truncate">
{formData.emailDomain || <span className="text-muted-foreground"> </span>}
</span>
)}
<Select <Select
value={formData.emailDomain || undefined} value={formData.emailDomain || undefined}
onValueChange={(value) => { onValueChange={(value) => {
@@ -205,6 +268,7 @@ export function SignupForm({
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
emailDomain: value, emailDomain: value,
customDomain: '',
userEmail: `${prev.emailId}@${value}`, userEmail: `${prev.emailId}@${value}`,
})); }));
} else { } else {
@@ -218,9 +282,7 @@ export function SignupForm({
}} }}
disabled={isLoading || isEmailVerified} disabled={isLoading || isEmailVerified}
> >
<SelectTrigger className="flex-1"> <SelectTrigger className="w-10 h-full border-0 bg-transparent px-0 focus:ring-0 rounded-l-none justify-center" />
<SelectValue placeholder="도메인 선택" />
</SelectTrigger>
<SelectContent> <SelectContent>
{emailDomains.map((domain) => ( {emailDomains.map((domain) => (
<SelectItem key={domain} value={domain}> <SelectItem key={domain} value={domain}>
@@ -229,7 +291,7 @@ export function SignupForm({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
)} </div>
</div> </div>
<Button <Button
type="button" type="button"
@@ -245,12 +307,11 @@ export function SignupForm({
emailCheckStatus === 'checking' emailCheckStatus === 'checking'
} }
variant={isCodeSent || isEmailVerified ? "secondary" : "outline"} variant={isCodeSent || isEmailVerified ? "secondary" : "outline"}
className="w-full" className="w-full h-11"
> >
{isSendingCode && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isSendingCode && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEmailVerified ? '인증완료' : isSendingCode ? '발송중...' : isCodeSent ? '발송완료' : '인증번호 발송'} {isEmailVerified ? '인증완료' : isSendingCode ? '발송중...' : isCodeSent ? '발송완료' : '인증번호 발송'}
</Button> </Button>
</div>
{emailCheckMessage && !isEmailVerified && ( {emailCheckMessage && !isEmailVerified && (
<div className={cn( <div className={cn(
"flex items-center gap-2 text-sm", "flex items-center gap-2 text-sm",
@@ -283,13 +344,14 @@ export function SignupForm({
placeholder="인증번호 6자리" placeholder="인증번호 6자리"
disabled={isLoading || isEmailVerified} disabled={isLoading || isEmailVerified}
maxLength={6} maxLength={6}
className="flex-1" className="flex-1 h-11"
/> />
<Button <Button
type="button" type="button"
onClick={onVerifyCode} onClick={onVerifyCode}
disabled={!verificationCode || isLoading || isEmailVerified || isVerifyingCode} disabled={!verificationCode || isLoading || isEmailVerified || isVerifyingCode}
variant="outline" variant="outline"
className="h-11"
> >
{isVerifyingCode && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isVerifyingCode && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isVerifyingCode ? '확인중...' : '확인'} {isVerifyingCode ? '확인중...' : '확인'}
@@ -298,7 +360,12 @@ export function SignupForm({
<FieldDescription> 6 .</FieldDescription> <FieldDescription> 6 .</FieldDescription>
</Field> </Field>
)} )}
</>
)}
{/* Step 3: 추가 정보 */}
{currentStep === 3 && (
<>
<Field> <Field>
<FieldLabel htmlFor="userPhone"> *</FieldLabel> <FieldLabel htmlFor="userPhone"> *</FieldLabel>
<Input <Input
@@ -309,6 +376,7 @@ export function SignupForm({
placeholder="010-0000-0000" placeholder="010-0000-0000"
disabled={isLoading} disabled={isLoading}
required required
className="h-11"
/> />
</Field> </Field>
@@ -323,37 +391,65 @@ export function SignupForm({
placeholder="소속 기관명을 입력하세요" placeholder="소속 기관명을 입력하세요"
disabled={isLoading} disabled={isLoading}
required={formData.userSe !== 'FARM'} required={formData.userSe !== 'FARM'}
className="h-11"
/> />
<FieldDescription>/ .</FieldDescription>
</Field> </Field>
)} )}
<Field> <Field>
<FieldLabel htmlFor="userPassword"> *</FieldLabel> <FieldLabel htmlFor="userPassword"> *</FieldLabel>
<div className="relative">
<Input <Input
id="userPassword" id="userPassword"
type="password" type={showPassword ? "text" : "password"}
value={formData.userPassword} value={formData.userPassword}
onChange={(e) => handleChange('userPassword', e.target.value)} onChange={(e) => handleChange('userPassword', e.target.value)}
placeholder="비밀번호를 입력하세요 (8자 이상)" placeholder="비밀번호를 입력하세요 (8자 이상)"
disabled={isLoading} disabled={isLoading}
required required
className="h-11 pr-10"
/> />
<FieldDescription>8 .</FieldDescription> <button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="confirmPassword"> *</FieldLabel> <FieldLabel htmlFor="confirmPassword"> *</FieldLabel>
<div className="relative">
<Input <Input
id="confirmPassword" id="confirmPassword"
type="password" type={showConfirmPassword ? "text" : "password"}
value={formData.confirmPassword} value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)} onChange={(e) => handleChange('confirmPassword', e.target.value)}
placeholder="비밀번호를 다시 입력하세요" placeholder="비밀번호를 다시 입력하세요"
disabled={isLoading} disabled={isLoading}
required required
className="h-11 pr-10"
/> />
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
tabIndex={-1}
>
{showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{formData.confirmPassword && formData.userPassword !== formData.confirmPassword && (
<FieldDescription className="text-red-600">
.
</FieldDescription>
)}
</Field> </Field>
</>
)}
{error && ( {error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-600"> <div className="rounded-md bg-red-50 p-3 text-sm text-red-600">
@@ -361,29 +457,51 @@ export function SignupForm({
</div> </div>
)} )}
<Field> {/* 네비게이션 버튼 */}
<div className="flex gap-2 pt-2">
{currentStep > 1 && (
<Button
type="button"
variant="outline"
onClick={handlePrev}
disabled={isLoading}
className="flex-1 h-11"
>
<ChevronLeft className="mr-1 h-4 w-4" />
</Button>
)}
{currentStep < TOTAL_STEPS ? (
<Button
type="button"
onClick={handleNext}
disabled={!canGoNext() || isLoading}
className="flex-1 h-11"
>
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
) : (
<Button <Button
type="submit" type="submit"
className="w-full" className="flex-1 h-11"
disabled={isLoading || !isEmailVerified} disabled={isLoading || !canGoNext()}
> >
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isLoading ? '회원가입 중...' : '회원가입'} {isLoading ? '회원가입 중...' : '회원가입'}
</Button> </Button>
{!isEmailVerified && (
<FieldDescription className="text-center text-amber-600">
.
</FieldDescription>
)} )}
</Field> </div>
<FieldSeparator></FieldSeparator>
<Field> <Field>
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
onClick={() => router.push('/login')} onClick={() => router.push('/login')}
disabled={isLoading} disabled={isLoading}
className="w-full" className="w-full h-11 border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300"
> >
</Button> </Button>

View File

@@ -226,9 +226,11 @@ type TraitName = keyof typeof DEFAULT_FILTER_SETTINGS.traitWeights
interface GlobalFilterDialogProps { interface GlobalFilterDialogProps {
externalOpen?: boolean externalOpen?: boolean
onExternalOpenChange?: (open: boolean) => void onExternalOpenChange?: (open: boolean) => void
geneCount?: number
traitCount?: number
} }
export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: GlobalFilterDialogProps = {}) { export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCount = 0, traitCount = 0 }: GlobalFilterDialogProps = {}) {
const { filters, updateFilters, resetFilters } = useGlobalFilter() const { filters, updateFilters, resetFilters } = useGlobalFilter()
const [internalOpen, setInternalOpen] = useState(false) const [internalOpen, setInternalOpen] = useState(false)
@@ -496,18 +498,19 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button <Button
variant={filters.isActive ? "default" : "outline"} variant={(geneCount > 0 || traitCount > 0) ? "default" : "outline"}
size="sm" size="sm"
data-filter-button data-filter-button
className="h-8 md:h-9 text-xs md:text-base font-semibold px-2 md:px-3" className="h-8 md:h-9 text-xs md:text-base font-semibold px-2 md:px-3 relative"
> >
<Settings2 className="h-3 w-3 md:h-4 md:w-4 mr-1 md:mr-1.5" /> <Settings2 className="h-3 w-3 md:h-4 md:w-4 mr-1 md:mr-1.5" />
<span></span> <span></span>
{filters.isActive ? ( <Badge
<Badge variant="secondary" className="ml-1 md:ml-1.5 text-[9px] md:text-xs px-1 py-0"></Badge> variant={(geneCount > 0 || traitCount > 0) ? "default" : "outline"}
) : ( className={`ml-1.5 text-[9px] md:text-xs px-1.5 py-0 ${(geneCount > 0 || traitCount > 0) ? "bg-white text-primary" : "text-muted-foreground"}`}
<Badge variant="outline" className="ml-1 md:ml-1.5 text-[9px] md:text-xs px-1 py-0 text-muted-foreground"></Badge> >
)} {(geneCount > 0 || traitCount > 0) ? "활성" : "비활성"}
</Badge>
</Button> </Button>
</SheetTrigger> </SheetTrigger>

View File

@@ -22,7 +22,7 @@ import { SidebarTrigger } from "@/components/ui/sidebar";
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"; import { useAnalysisYear } from "@/contexts/AnalysisYearContext";
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"; import { useGlobalFilter } from "@/contexts/GlobalFilterContext";
import { useAuthStore } from "@/store/auth-store"; import { useAuthStore } from "@/store/auth-store";
import { Filter, LogOut, User } from "lucide-react"; import { LogOut, User } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export function SiteHeader() { export function SiteHeader() {
@@ -36,39 +36,11 @@ export function SiteHeader() {
router.push('/login'); router.push('/login');
}; };
// 필터 활성화 여부 확인 // 활성화된 필터 개수 계산
const hasActiveFilters = filters.isActive && ( const geneCount = filters.selectedGenes.length;
filters.selectedGenes.length > 0 || const traitCount = filters.traitWeights
(filters.traitWeights && Object.values(filters.traitWeights).some(w => w > 0)) || ? Object.values(filters.traitWeights).filter(weight => weight > 0).length
(filters.inbreedingThreshold !== undefined && filters.inbreedingThreshold > 0) : 0;
);
// 필터 요약 텍스트 생성
const getFilterSummary = () => {
const parts: string[] = [];
// 유전자 - 개수만 표시
if (filters.selectedGenes.length > 0) {
parts.push(`유전자 ${filters.selectedGenes.length}`);
}
// 유전체 - 개수만 표시
if (filters.traitWeights) {
const activeWeightCount = Object.values(filters.traitWeights)
.filter(weight => weight > 0).length;
if (activeWeightCount > 0) {
parts.push(`유전체 ${activeWeightCount}`);
}
}
// 근친도 표시 (항상)
if (filters.inbreedingThreshold !== undefined && filters.inbreedingThreshold > 0) {
parts.push(`근친도 ${filters.inbreedingThreshold}%`);
}
return parts.join(' · ');
};
return ( return (
<> <>
@@ -77,24 +49,6 @@ export function SiteHeader() {
{/* 왼쪽 영역 */} {/* 왼쪽 영역 */}
<div className="flex items-center gap-2 md:gap-3 min-w-0 overflow-hidden"> <div className="flex items-center gap-2 md:gap-3 min-w-0 overflow-hidden">
<SidebarTrigger className="h-9 w-9 md:h-10 md:w-10 flex-shrink-0" /> <SidebarTrigger className="h-9 w-9 md:h-10 md:w-10 flex-shrink-0" />
{/* 활성화된 필터 표시 - 반응형 개선 */}
{hasActiveFilters && (
<div className="flex items-center gap-1 md:gap-2 px-1.5 md:px-3 py-1 md:py-2 bg-primary/10 rounded-md md:rounded-lg min-w-0">
<Filter className="h-4 w-4 md:h-4 md:w-4 text-primary flex-shrink-0" />
{/* 모바일: 숫자만, 데스크톱: 전체 텍스트 */}
<span className="text-[10px] md:text-sm text-primary font-medium truncate md:hidden">
{(() => {
const count = filters.selectedGenes.length +
(filters.traitWeights ? Object.values(filters.traitWeights).filter(w => w > 0).length : 0);
return count;
})()}
</span>
<span className="hidden md:inline text-sm text-primary font-medium truncate max-w-[200px] lg:max-w-[350px]">
{getFilterSummary()}
</span>
</div>
)}
</div> </div>
{/* 오른쪽 영역 */} {/* 오른쪽 영역 */}
@@ -119,7 +73,7 @@ export function SiteHeader() {
</Select> </Select>
{/* 필터 버튼 */} {/* 필터 버튼 */}
<GlobalFilterDialog /> <GlobalFilterDialog geneCount={geneCount} traitCount={traitCount} />
{/* 사용자 메뉴 */} {/* 사용자 메뉴 */}
<DropdownMenu> <DropdownMenu>

View File

@@ -164,8 +164,11 @@ export const genomeApi = {
/** /**
* GET /genome/farm-region-ranking/:farmNo - 농가의 보은군 내 순위 조회 (대시보드용) * GET /genome/farm-region-ranking/:farmNo - 농가의 보은군 내 순위 조회 (대시보드용)
*/ */
getFarmRegionRanking: async (farmNo: number): Promise<FarmRegionRankingDto> => { getFarmRegionRanking: async (
return await apiClient.get(`/genome/farm-region-ranking/${farmNo}`); farmNo: number,
traitConditions?: { traitNm: string; weight?: number }[]
): Promise<FarmRegionRankingDto> => {
return await apiClient.post(`/genome/farm-region-ranking/${farmNo}`, { traitConditions });
}, },
/** /**
@@ -291,7 +294,8 @@ export interface DashboardStatsDto {
traitName: string; traitName: string;
category: string; category: string;
avgEbv: number; avgEbv: number;
avgEpd: number; // 육종가(EPD) 평균 avgEpd: number; // 농가 육종가(EPD) 평균
regionAvgEpd: number; // 보은군 육종가(EPD) 평균
avgPercentile: number; avgPercentile: number;
count: number; count: number;
rank: number | null; // 보은군 내 농가 순위 rank: number | null; // 보은군 내 농가 순위