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 = [
'KOR002191642861', // 1회 분석 반려내역서 재분석 불가능
'KOR002191642861',
// 일치인데 정보가 없음 / 김정태님 유전체 내역 빠짐 1두
// 일단 모근 1회분량이고 재검사어려움 , 모근상태 불량으로 인한 DNA분해로 인해 분석불가 상태로 넣음
];
//=================================================================================================================

View File

@@ -228,25 +228,36 @@ export class CowService {
// 각 개체별로 점수 계산
const cowsWithScore = await Promise.all(
cows.map(async (cow) => {
// Step 1: cowId로 직접 형질 상세 데이터 조회 (35개 형질의 EBV 값)
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: 해당 개체의 최신 유전체 분석 의뢰 조회 (친자감별 확인용)
// Step 1: 해당 개체의 최신 유전체 분석 의뢰 조회 (친자감별 확인용)
const latestRequest = await this.genomeRequestRepository.findOne({
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
order: { requestDt: 'DESC', regDt: 'DESC' },
});
// Step 3: 친자감별 확인 - 아비 KPN "일치"가 아니면 분석 불가
// Step 2: 친자감별 확인 - 유효하지 않으면 분석 불가
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: 가중 평균 계산

View File

@@ -45,9 +45,12 @@ export class GenomeController {
* 농가의 보은군 내 순위 조회 (대시보드용)
* @param farmNo - 농장 번호
*/
@Get('farm-region-ranking/:farmNo')
getFarmRegionRanking(@Param('farmNo') farmNo: string) {
return this.genomeService.getFarmRegionRanking(+farmNo);
@Post('farm-region-ranking/:farmNo')
getFarmRegionRanking(
@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 totalWeight = 0; // Σ(가중치)
let percentileSum = 0; // 백분위 합계 (평균 계산용)
let percentileCount = 0; // 백분위 개수
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) {
const trait = traitDetails.find((d) => d.traitName === condition.traitNm);
@@ -1359,8 +1364,8 @@ export class GenomeService {
}
}
// Step 6: 최종 점수 계산 (모든 선택 형질이 있어야만 계산)
const score = (hasAllTraits && totalWeight > 0) ? weightedSum / totalWeight : null;
// Step 6: 최종 점수 계산 (모든 선택 형질이 있어야만 계산) ================================================================
const score = (hasAllTraits && totalWeight > 0) ? weightedSum : null;
const percentile = percentileCount > 0 ? percentileSum / percentileCount : null;
// Step 7: 현재 개체의 농장/지역 정보 조회
@@ -1482,7 +1487,7 @@ export class GenomeService {
// 모든 선택 형질이 있는 경우만 점수에 포함
if (hasAllTraits && totalWeight > 0) {
const score = weightedSum / totalWeight;
const score = weightedSum;
allScores.push({
cowId: request.cow.cowId,
score: Math.round(score * 100) / 100,
@@ -1494,6 +1499,7 @@ export class GenomeService {
// 점수 기준 내림차순 정렬
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;
@@ -1667,7 +1673,10 @@ export class GenomeService {
* 성능 최적화: N+1 쿼리 문제 해결 - 모든 데이터를 한 번에 조회 후 메모리에서 처리
* @param farmNo - 농장 번호
*/
async getFarmRegionRanking(farmNo: number): Promise<{
async getFarmRegionRanking(
farmNo: number,
inputTraitConditions?: { traitNm: string; weight?: number }[]
): Promise<{
farmNo: number;
farmerName: string | null;
farmAvgScore: number | null;
@@ -1728,7 +1737,12 @@ export class GenomeService {
'안심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 쿼리 없음)
const allScores: { cowId: string; score: number; farmNo: number | null }[] = [];
@@ -1758,7 +1772,7 @@ export class GenomeService {
}
if (hasAllTraits && totalWeight > 0) {
const score = weightedSum / totalWeight;
const score = weightedSum;
allScores.push({
cowId: request.cow.cowId,
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
: 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 {
farmNo,
farmerName: farm.farmerName || null,

View File

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

View File

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

View File

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

View File

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

View File

@@ -145,10 +145,17 @@ export default function DashboardPage() {
useEffect(() => {
const fetchStats = async () => {
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 {
const [statsData, rankingData] = await Promise.all([
genomeApi.getDashboardStats(farmNo),
genomeApi.getFarmRegionRanking(farmNo)
genomeApi.getFarmRegionRanking(farmNo, traitConditions)
])
setStats(statsData)
setFarmRanking(rankingData)
@@ -157,7 +164,7 @@ export default function DashboardPage() {
}
}
fetchStats()
}, [farmNo])
}, [farmNo, filters.isActive, filters.selectedTraits, filters.traitWeights])
// 연도별 형질 추이 데이터 로드
useEffect(() => {
@@ -268,9 +275,10 @@ export default function DashboardPage() {
// 개별 형질 선택 시 - traitAverages에서 해당 형질 찾기
const traitData = stats?.traitAverages?.find(t => t.traitName === distributionBasis)
if (traitData) {
farmScore = traitData.avgEbv ?? 0
// 보은군 평균은 0으로 가정 (표준화 육종가 기준)
regionScore = 0
const farmEpd = traitData.avgEpd ?? 0
const regionEpd = traitData.regionAvgEpd ?? 0
farmScore = farmEpd - regionEpd // 보은군 대비 차이
regionScore = 0 // 보은군 = 기준점 (0)
label = distributionBasis
rank = traitData.rank ?? null
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 range = Math.max(2.5, Math.ceil(absMax * 2) / 2 + 0.5)
const absMax = Math.max(Math.abs(farmScore), 0.1)
// 데이터 범위에 따라 적절한 X축 범위 계산
// absMax의 1.5배를 범위로 하고, 깔끔한 숫자로 반올림
const rawRange = absMax * 1.5
let range: number
let step: number
// 정규분포 비율 (0.5σ 단위)
const sigmaPercentMap: Record<string, number> = {
'-3.0': 0.10, '-2.5': 0.52, '-2.0': 1.65, '-1.5': 4.41,
'-1.0': 9.18, '-0.5': 15.00, '0.0': 19.14, '0.5': 19.14,
'1.0': 15.00, '1.5': 9.18, '2.0': 4.41, '2.5': 1.65, '3.0': 0.52,
if (rawRange <= 3) {
// 표준화 육종가 스케일 (전체 선발지수)
range = Math.max(2.5, Math.ceil(rawRange * 2) / 2)
step = 0.5
} 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 = []
for (let sigma = -range; sigma < range; sigma += 0.5) {
const key = sigma.toFixed(1)
const percent = sigmaPercentMap[key] ?? 0.1
const total = farmRanking.totalFarmsInRegion || 50
// 정규분포 PDF 기반으로 각 구간의 비율 계산
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
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({
range: `${sigma}σ~${sigma + 0.5}σ`,
min: sigma,
max: sigma + 0.5,
midPoint: sigma + 0.25,
count: Math.round(total * percent / 100),
percent
range: `${bin.min.toFixed(1)}~${bin.max.toFixed(1)}`,
min: bin.min,
max: bin.max,
midPoint: bin.midPoint,
count: Math.round(total * normalizedPercent / 100),
percent: normalizedPercent
})
}
@@ -636,22 +676,27 @@ export default function DashboardPage() {
? (actualLineLength < minLineLength ? farmX - minLineLength : regionX + 5)
: (actualLineLength < minLineLength ? farmX + minLineLength : regionX - 5)
// 화살표 머리 시작점 (막대기 끝점)
const arrowHeadStart = diff >= 0
? farmX - arrowBaseOffset - arrowTipOffset
: farmX + arrowBaseOffset + arrowTipOffset
return (
<g>
{/* 막대선: 최소 길이 보장 */}
{/* 막대선: 화살표 머리 시작점까지만 */}
<line
x1={lineStartX}
y1={arrowY}
x2={farmX}
x2={arrowHeadStart}
y2={arrowY}
stroke={color}
strokeWidth={isMobileView ? 3 : 4}
/>
{/* 화살표 머리 (농가선 넘어서, 뾰족한 화살표) */}
{/* 화살표 머리 (농가선에 딱 맞게) */}
<polygon
points={diff >= 0
? `${farmX - arrowBaseOffset},${arrowY - arrowHeight} ${farmX - arrowBaseOffset},${arrowY + arrowHeight} ${farmX + arrowTipOffset},${arrowY}`
: `${farmX + arrowBaseOffset},${arrowY - arrowHeight} ${farmX + arrowBaseOffset},${arrowY + arrowHeight} ${farmX - arrowTipOffset},${arrowY}`
? `${arrowHeadStart},${arrowY - arrowHeight} ${arrowHeadStart},${arrowY + arrowHeight} ${farmX},${arrowY}`
: `${arrowHeadStart},${arrowY - arrowHeight} ${arrowHeadStart},${arrowY + arrowHeight} ${farmX},${arrowY}`
}
fill={color}
/>
@@ -1169,12 +1214,13 @@ export default function DashboardPage() {
{/* 우측: 상세 바 차트 - 68% */}
<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 barWidth = Math.min(Math.abs(avgEpd) / maxAbs * 45, 48)
const isFirst = index === 0
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">
<span className="text-sm font-bold text-slate-700">{category}</span>
@@ -1201,15 +1247,22 @@ export default function DashboardPage() {
</div>
{/* 데스크톱: 한 줄 레이아웃 */}
<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="absolute left-1/2 top-0 bottom-0 w-0.5 bg-slate-400 z-10"></div>
<div
className={`absolute top-1 bottom-1 rounded-full transition-all duration-500 ${isPositive ? 'bg-[#1F3A8F]' : 'bg-red-400'}`}
style={{
left: isPositive ? '50%' : `${50 - barWidth}%`,
width: `${barWidth}%`,
}}
/>
<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 top-1 bottom-1 rounded-full transition-all duration-500 ${isPositive ? 'bg-[#1F3A8F]' : 'bg-red-400'}`}
style={{
left: isPositive ? '50%' : `${50 - barWidth}%`,
width: `${barWidth}%`,
}}
/>
</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'}`}>
{isPositive ? '+' : ''}{avgEpd.toFixed(2)}

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 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">
<FindIdForm />
</div>

View File

@@ -31,7 +31,7 @@ export default function FindPwPage() {
/>
</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">
<FindPwForm />
</div>

View File

@@ -2,24 +2,43 @@
import { LoginForm } from "@/components/auth/login-form";
import { useAuthStore } from '@/store/auth-store';
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useState, useEffect } from "react";
import Image from "next/image";
const SAVED_USER_ID_KEY = 'savedUserId';
// 로그인 페이지 컴포넌트
export default function LoginPage() {
const [userId, setUserId] = useState('')
const [userPassword, setUserPassword] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [saveId, setSaveId] = useState(false)
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) => {
e.preventDefault()
setError('')
setIsLoading(true)
// 아이디 저장 처리
if (saveId) {
localStorage.setItem(SAVED_USER_ID_KEY, userId)
} else {
localStorage.removeItem(SAVED_USER_ID_KEY)
}
try {
await login({
userId: userId,
@@ -72,7 +91,7 @@ export default function LoginPage() {
/>
</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">
<LoginForm
onSubmit={handleLogin}
@@ -82,6 +101,8 @@ export default function LoginPage() {
setUserPassword={setUserPassword}
error={error}
isLoading={isLoading}
saveId={saveId}
setSaveId={setSaveId}
/>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,14 +2,14 @@ import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { Loader2, Eye, EyeOff } from "lucide-react";
import { useState } from "react";
export function LoginForm({
className,
@@ -20,6 +20,8 @@ export function LoginForm({
setUserPassword,
error,
isLoading,
saveId,
setSaveId,
...props
}: React.ComponentProps<"form"> & {
onSubmit: (e: React.FormEvent) => void;
@@ -29,15 +31,17 @@ export function LoginForm({
setUserPassword: (value: string) => void;
error?: string;
isLoading?: boolean;
saveId: boolean;
setSaveId: (value: boolean) => void;
}) {
const router = useRouter();
const [showPassword, setShowPassword] = useState(false);
return (
<form className={cn("flex flex-col gap-4", className)} onSubmit={onSubmit} {...props}>
<FieldGroup>
<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>
@@ -51,23 +55,47 @@ export function LoginForm({
onChange={(e) => setUserId(e.target.value)}
placeholder="아이디를 입력하세요"
disabled={isLoading}
className="h-11"
required
/>
</Field>
<Field>
<div className="flex items-center">
<div className="flex items-center justify-between">
<FieldLabel htmlFor="password"></FieldLabel>
<a href="/findpw" className="text-xs text-primary hover:underline">
</a>
</div>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={userPassword}
onChange={(e) => setUserPassword(e.target.value)}
placeholder="비밀번호를 입력하세요"
disabled={isLoading}
className="h-11 pr-10"
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>
<Input
id="password"
type="password"
value={userPassword}
onChange={(e) => setUserPassword(e.target.value)}
placeholder="비밀번호를 입력하세요"
disabled={isLoading}
required
/>
</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 && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-600">
{error}
@@ -76,36 +104,30 @@ export function LoginForm({
<Field>
<Button
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}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isLoading ? '로그인 중...' : '로그인'}
</Button>
</Field>
<FieldSeparator> ?</FieldSeparator>
<FieldSeparator></FieldSeparator>
<Field>
<Button
variant="outline"
type="button"
onClick={() => router.push('/signup')}
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>
<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>
<div className="text-center">
<a href="/findid" className="text-sm text-gray-500 hover:text-primary">
?
</a>
</div>
</FieldGroup>
</form>
)

View File

@@ -5,6 +5,7 @@ import {
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
@@ -15,9 +16,9 @@ import {
SelectValue,
} from "@/components/ui/select"
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 { Dispatch, SetStateAction } from "react"
import { Dispatch, SetStateAction, useState } from "react"
interface SignupFormProps extends React.ComponentProps<"form"> {
onSubmit: (e: React.FormEvent) => void;
@@ -35,8 +36,12 @@ interface SignupFormProps extends React.ComponentProps<"form"> {
isVerifyingCode: boolean;
emailCheckStatus: 'idle' | 'checking' | 'available' | 'unavailable';
emailCheckMessage: string;
currentStep: number;
setCurrentStep: Dispatch<SetStateAction<number>>;
}
const TOTAL_STEPS = 3;
export function SignupForm({
className,
onSubmit,
@@ -54,9 +59,13 @@ export function SignupForm({
isVerifyingCode,
emailCheckStatus,
emailCheckMessage,
currentStep,
setCurrentStep,
...props
}: SignupFormProps) {
const router = useRouter();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const handleChange = (field: keyof SignupFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
@@ -72,122 +81,208 @@ 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 (
<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>
<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>
<p className="text-muted-foreground text-sm text-balance">
<p className="text-muted-foreground text-sm">
{getStepTitle()}
</p>
</div>
<Field>
<FieldLabel htmlFor="userSe"> *</FieldLabel>
<Select
value={formData.userSe}
onValueChange={(value) => handleChange('userSe', value)}
disabled={isLoading}
required
>
<SelectTrigger>
<SelectValue placeholder="회원 유형을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="FARM"></SelectItem>
<SelectItem value="CNSLT"></SelectItem>
<SelectItem value="ORGAN"></SelectItem>
</SelectContent>
</Select>
<FieldDescription> .</FieldDescription>
</Field>
{/* 진행률 표시 */}
<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>
<Field>
<FieldLabel htmlFor="userId"> *</FieldLabel>
<Input
id="userId"
type="text"
value={formData.userId}
onChange={(e) => handleChange('userId', e.target.value)}
placeholder="아이디를 입력하세요 (4자 이상)"
disabled={isLoading}
required
/>
<FieldDescription>4 .</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="userName"> *</FieldLabel>
<Input
id="userName"
type="text"
value={formData.userName}
onChange={(e) => handleChange('userName', e.target.value)}
placeholder="이름을 입력하세요 (2자 이상)"
disabled={isLoading}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="userEmail"> *</FieldLabel>
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<Input
id="emailId"
type="text"
value={formData.emailId}
onChange={(e) => {
const newEmailId = e.target.value;
setFormData((prev) => {
const domain = prev.emailDomain === '직접입력' ? '' : prev.emailDomain;
return {
...prev,
emailId: newEmailId,
userEmail: domain ? `${newEmailId}@${domain}` : newEmailId,
};
});
}}
placeholder="이메일 아이디"
disabled={isLoading || isEmailVerified}
{/* Step 1: 기본 정보 */}
{currentStep === 1 && (
<>
<Field>
<FieldLabel htmlFor="userSe"> *</FieldLabel>
<Select
value={formData.userSe}
onValueChange={(value) => handleChange('userSe', value)}
disabled={isLoading}
required
className="flex-1"
>
<SelectTrigger className="h-11 w-full">
<SelectValue placeholder="회원 유형을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="FARM"></SelectItem>
<SelectItem value="CNSLT"></SelectItem>
<SelectItem value="ORGAN"></SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="userId"> *</FieldLabel>
<Input
id="userId"
type="text"
value={formData.userId}
onChange={(e) => handleChange('userId', e.target.value)}
placeholder="아이디를 입력하세요 (4자 이상)"
disabled={isLoading}
required
className="h-11"
/>
<span className="text-muted-foreground">@</span>
{formData.emailDomain === '직접입력' ? (
<div className="flex flex-1">
<Input
id="customDomain"
type="text"
placeholder="도메인 주소"
value={formData.customDomain || ''}
onChange={(e) => {
const newDomain = e.target.value;
setFormData((prev) => ({
</Field>
<Field>
<FieldLabel htmlFor="userName"> *</FieldLabel>
<Input
id="userName"
type="text"
value={formData.userName}
onChange={(e) => handleChange('userName', e.target.value)}
placeholder="이름을 입력하세요 (2자 이상)"
disabled={isLoading}
required
className="h-11"
/>
</Field>
</>
)}
{/* Step 2: 이메일 인증 */}
{currentStep === 2 && (
<>
<Field>
<FieldLabel htmlFor="userEmail"> *</FieldLabel>
<div className="flex gap-2 items-center">
<Input
id="emailId"
type="text"
value={formData.emailId}
onChange={(e) => {
const newEmailId = e.target.value;
setFormData((prev) => {
const domain = prev.emailDomain === '직접입력' ? prev.customDomain : prev.emailDomain;
return {
...prev,
customDomain: newDomain,
userEmail: `${prev.emailId}@${newDomain}`,
}));
}}
disabled={isLoading || isEmailVerified}
required
className="flex-1 rounded-r-none border-r-0 h-9 !text-sm px-3 py-2"
/>
emailId: newEmailId,
userEmail: domain ? `${newEmailId}@${domain}` : newEmailId,
};
});
}}
placeholder="이메일"
disabled={isLoading || isEmailVerified}
required
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">
{formData.emailDomain === '직접입력' ? (
<Input
id="customDomain"
type="text"
placeholder="도메인 입력"
value={formData.customDomain || ''}
onChange={(e) => {
const newDomain = e.target.value;
setFormData((prev) => ({
...prev,
customDomain: newDomain,
userEmail: `${prev.emailId}@${newDomain}`,
}));
}}
disabled={isLoading || isEmailVerified}
required
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">
{formData.emailDomain || <span className="text-muted-foreground"> </span>}
</span>
)}
<Select
value=""
value={formData.emailDomain || undefined}
onValueChange={(value) => {
setFormData((prev) => ({
...prev,
emailDomain: value,
customDomain: '',
userEmail: `${prev.emailId}@${value}`,
}));
if (value !== '직접입력') {
setFormData((prev) => ({
...prev,
emailDomain: value,
customDomain: '',
userEmail: `${prev.emailId}@${value}`,
}));
} else {
setFormData((prev) => ({
...prev,
emailDomain: value,
customDomain: '',
userEmail: prev.emailId,
}));
}
}}
disabled={isLoading || isEmailVerified}
>
<SelectTrigger className="w-[50px] rounded-l-none -ml-px px-2 h-9 [&>svg]:size-4">
<SelectValue />
</SelectTrigger>
<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}>
@@ -197,193 +292,216 @@ export function SignupForm({
</SelectContent>
</Select>
</div>
) : (
<Select
value={formData.emailDomain || undefined}
onValueChange={(value) => {
if (value !== '직접입력') {
setFormData((prev) => ({
...prev,
emailDomain: value,
userEmail: `${prev.emailId}@${value}`,
}));
} else {
setFormData((prev) => ({
...prev,
emailDomain: value,
customDomain: '',
userEmail: prev.emailId,
}));
}
}}
disabled={isLoading || isEmailVerified}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="도메인 선택" />
</SelectTrigger>
<SelectContent>
{emailDomains.map((domain) => (
<SelectItem key={domain} value={domain}>
{domain}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<Button
type="button"
onClick={onSendVerificationCode}
disabled={
!formData.emailId ||
(formData.emailDomain === '직접입력' ? !formData.customDomain : !formData.emailDomain) ||
isLoading ||
isEmailVerified ||
isSendingCode ||
isCodeSent ||
emailCheckStatus === 'unavailable' ||
emailCheckStatus === 'checking'
}
variant={isCodeSent || isEmailVerified ? "secondary" : "outline"}
className="w-full"
>
{isSendingCode && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEmailVerified ? '인증완료' : isSendingCode ? '발송중...' : isCodeSent ? '발송완료' : '인증번호 발송'}
</Button>
</div>
{emailCheckMessage && !isEmailVerified && (
<div className={cn(
"flex items-center gap-2 text-sm",
emailCheckStatus === 'checking' && "text-muted-foreground",
emailCheckStatus === 'available' && "text-green-600",
emailCheckStatus === 'unavailable' && "text-red-600"
)}>
{emailCheckStatus === 'checking' && <Loader2 className="h-4 w-4 animate-spin" />}
{emailCheckStatus === 'available' && <CheckCircle2 className="h-4 w-4" />}
{emailCheckStatus === 'unavailable' && <XCircle className="h-4 w-4" />}
<span>{emailCheckMessage}</span>
</div>
)}
{isEmailVerified && (
<FieldDescription className="text-green-600">
.
</FieldDescription>
)}
</Field>
{!isEmailVerified && isCodeSent && formData.userEmail && (
<Field>
<FieldLabel htmlFor="verificationCode"></FieldLabel>
<div className="flex gap-2">
<Input
id="verificationCode"
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="인증번호 6자리"
disabled={isLoading || isEmailVerified}
maxLength={6}
className="flex-1"
/>
</div>
<Button
type="button"
onClick={onVerifyCode}
disabled={!verificationCode || isLoading || isEmailVerified || isVerifyingCode}
variant="outline"
onClick={onSendVerificationCode}
disabled={
!formData.emailId ||
(formData.emailDomain === '직접입력' ? !formData.customDomain : !formData.emailDomain) ||
isLoading ||
isEmailVerified ||
isSendingCode ||
isCodeSent ||
emailCheckStatus === 'unavailable' ||
emailCheckStatus === 'checking'
}
variant={isCodeSent || isEmailVerified ? "secondary" : "outline"}
className="w-full h-11"
>
{isVerifyingCode && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isVerifyingCode ? '확인중...' : '확인'}
{isSendingCode && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEmailVerified ? '인증완료' : isSendingCode ? '발송중...' : isCodeSent ? '발송완료' : '인증번호 발송'}
</Button>
</div>
<FieldDescription> 6 .</FieldDescription>
</Field>
{emailCheckMessage && !isEmailVerified && (
<div className={cn(
"flex items-center gap-2 text-sm",
emailCheckStatus === 'checking' && "text-muted-foreground",
emailCheckStatus === 'available' && "text-green-600",
emailCheckStatus === 'unavailable' && "text-red-600"
)}>
{emailCheckStatus === 'checking' && <Loader2 className="h-4 w-4 animate-spin" />}
{emailCheckStatus === 'available' && <CheckCircle2 className="h-4 w-4" />}
{emailCheckStatus === 'unavailable' && <XCircle className="h-4 w-4" />}
<span>{emailCheckMessage}</span>
</div>
)}
{isEmailVerified && (
<FieldDescription className="text-green-600">
.
</FieldDescription>
)}
</Field>
{!isEmailVerified && isCodeSent && formData.userEmail && (
<Field>
<FieldLabel htmlFor="verificationCode"></FieldLabel>
<div className="flex gap-2">
<Input
id="verificationCode"
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="인증번호 6자리"
disabled={isLoading || isEmailVerified}
maxLength={6}
className="flex-1 h-11"
/>
<Button
type="button"
onClick={onVerifyCode}
disabled={!verificationCode || isLoading || isEmailVerified || isVerifyingCode}
variant="outline"
className="h-11"
>
{isVerifyingCode && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isVerifyingCode ? '확인중...' : '확인'}
</Button>
</div>
<FieldDescription> 6 .</FieldDescription>
</Field>
)}
</>
)}
<Field>
<FieldLabel htmlFor="userPhone"> *</FieldLabel>
<Input
id="userPhone"
type="tel"
value={formData.userPhone}
onChange={(e) => handleChange('userPhone', e.target.value)}
placeholder="010-0000-0000"
disabled={isLoading}
required
/>
</Field>
{/* Step 3: 추가 정보 */}
{currentStep === 3 && (
<>
<Field>
<FieldLabel htmlFor="userPhone"> *</FieldLabel>
<Input
id="userPhone"
type="tel"
value={formData.userPhone}
onChange={(e) => handleChange('userPhone', e.target.value)}
placeholder="010-0000-0000"
disabled={isLoading}
required
className="h-11"
/>
</Field>
{formData.userSe !== 'FARM' && formData.userSe && (
<Field>
<FieldLabel htmlFor="userInstName"> *</FieldLabel>
<Input
id="userInstName"
type="text"
value={formData.userInstName || ''}
onChange={(e) => handleChange('userInstName', e.target.value)}
placeholder="소속 기관명을 입력하세요"
disabled={isLoading}
required={formData.userSe !== 'FARM'}
/>
<FieldDescription>/ .</FieldDescription>
</Field>
{formData.userSe !== 'FARM' && formData.userSe && (
<Field>
<FieldLabel htmlFor="userInstName"> *</FieldLabel>
<Input
id="userInstName"
type="text"
value={formData.userInstName || ''}
onChange={(e) => handleChange('userInstName', e.target.value)}
placeholder="소속 기관명을 입력하세요"
disabled={isLoading}
required={formData.userSe !== 'FARM'}
className="h-11"
/>
</Field>
)}
<Field>
<FieldLabel htmlFor="userPassword"> *</FieldLabel>
<div className="relative">
<Input
id="userPassword"
type={showPassword ? "text" : "password"}
value={formData.userPassword}
onChange={(e) => handleChange('userPassword', e.target.value)}
placeholder="비밀번호를 입력하세요 (8자 이상)"
disabled={isLoading}
required
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"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</Field>
<Field>
<FieldLabel htmlFor="confirmPassword"> *</FieldLabel>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)}
placeholder="비밀번호를 다시 입력하세요"
disabled={isLoading}
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>
<FieldLabel htmlFor="userPassword"> *</FieldLabel>
<Input
id="userPassword"
type="password"
value={formData.userPassword}
onChange={(e) => handleChange('userPassword', e.target.value)}
placeholder="비밀번호를 입력하세요 (8자 이상)"
disabled={isLoading}
required
/>
<FieldDescription>8 .</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="confirmPassword"> *</FieldLabel>
<Input
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)}
placeholder="비밀번호를 다시 입력하세요"
disabled={isLoading}
required
/>
</Field>
{error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-600">
{error}
</div>
)}
<Field>
<Button
type="submit"
className="w-full"
disabled={isLoading || !isEmailVerified}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isLoading ? '회원가입 중...' : '회원가입'}
</Button>
{!isEmailVerified && (
<FieldDescription className="text-center text-amber-600">
.
</FieldDescription>
{/* 네비게이션 버튼 */}
<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>
)}
</Field>
{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
type="submit"
className="flex-1 h-11"
disabled={isLoading || !canGoNext()}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isLoading ? '회원가입 중...' : '회원가입'}
</Button>
)}
</div>
<FieldSeparator></FieldSeparator>
<Field>
<Button
variant="outline"
type="button"
onClick={() => router.push('/login')}
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>

View File

@@ -226,9 +226,11 @@ type TraitName = keyof typeof DEFAULT_FILTER_SETTINGS.traitWeights
interface GlobalFilterDialogProps {
externalOpen?: boolean
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 [internalOpen, setInternalOpen] = useState(false)
@@ -496,18 +498,19 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant={filters.isActive ? "default" : "outline"}
variant={(geneCount > 0 || traitCount > 0) ? "default" : "outline"}
size="sm"
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" />
<span></span>
{filters.isActive ? (
<Badge variant="secondary" className="ml-1 md:ml-1.5 text-[9px] md:text-xs px-1 py-0"></Badge>
) : (
<Badge variant="outline" className="ml-1 md:ml-1.5 text-[9px] md:text-xs px-1 py-0 text-muted-foreground"></Badge>
)}
<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"}`}
>
{(geneCount > 0 || traitCount > 0) ? "활성" : "비활성"}
</Badge>
</Button>
</SheetTrigger>

View File

@@ -22,7 +22,7 @@ import { SidebarTrigger } from "@/components/ui/sidebar";
import { useAnalysisYear } from "@/contexts/AnalysisYearContext";
import { useGlobalFilter } from "@/contexts/GlobalFilterContext";
import { useAuthStore } from "@/store/auth-store";
import { Filter, LogOut, User } from "lucide-react";
import { LogOut, User } from "lucide-react";
import { useRouter } from "next/navigation";
export function SiteHeader() {
@@ -36,39 +36,11 @@ export function SiteHeader() {
router.push('/login');
};
// 필터 활성화 여부 확인
const hasActiveFilters = filters.isActive && (
filters.selectedGenes.length > 0 ||
(filters.traitWeights && Object.values(filters.traitWeights).some(w => w > 0)) ||
(filters.inbreedingThreshold !== undefined && filters.inbreedingThreshold > 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(' · ');
};
// 활성화된 필터 개수 계산
const geneCount = filters.selectedGenes.length;
const traitCount = filters.traitWeights
? Object.values(filters.traitWeights).filter(weight => weight > 0).length
: 0;
return (
<>
@@ -77,24 +49,6 @@ export function SiteHeader() {
{/* 왼쪽 영역 */}
<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" />
{/* 활성화된 필터 표시 - 반응형 개선 */}
{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>
{/* 오른쪽 영역 */}
@@ -119,7 +73,7 @@ export function SiteHeader() {
</Select>
{/* 필터 버튼 */}
<GlobalFilterDialog />
<GlobalFilterDialog geneCount={geneCount} traitCount={traitCount} />
{/* 사용자 메뉴 */}
<DropdownMenu>

View File

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