페이지 수정사항 반영

This commit is contained in:
2025-12-12 08:01:59 +09:00
parent 7d15c9be7c
commit dce58470b6
20 changed files with 1080 additions and 155 deletions

View File

@@ -31,6 +31,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { useAuthStore } from "@/store/auth-store"
import { AuthGuard } from "@/components/auth/auth-guard"
import {
IconSearch,
IconDownload,
@@ -460,9 +461,11 @@ function GenomeMappingContent() {
export default function GenomeMappingPage() {
return (
<SidebarProvider>
<AdminSidebar />
<GenomeMappingContent />
</SidebarProvider>
<AuthGuard>
<SidebarProvider>
<AdminSidebar />
<GenomeMappingContent />
</SidebarProvider>
</AuthGuard>
)
}

View File

@@ -13,6 +13,7 @@ import {
CardTitle,
} from "@/components/ui/card"
import { useAuthStore } from "@/store/auth-store"
import { AuthGuard } from "@/components/auth/auth-guard"
import {
IconFileUpload,
IconUsers,
@@ -165,9 +166,11 @@ function AdminDashboardContent() {
export default function AdminDashboardPage() {
return (
<SidebarProvider>
<AdminSidebar />
<AdminDashboardContent />
</SidebarProvider>
<AuthGuard>
<SidebarProvider>
<AdminSidebar />
<AdminDashboardContent />
</SidebarProvider>
</AuthGuard>
)
}

View File

@@ -14,6 +14,7 @@ import {
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { useAuthStore } from "@/store/auth-store"
import { AuthGuard } from "@/components/auth/auth-guard"
import {
IconUpload,
IconCheck,
@@ -416,9 +417,11 @@ function AdminUploadContent() {
export default function AdminUploadPage() {
return (
<SidebarProvider>
<AdminSidebar />
<AdminUploadContent />
</SidebarProvider>
<AuthGuard>
<SidebarProvider>
<AdminSidebar />
<AdminUploadContent />
</SidebarProvider>
</AuthGuard>
)
}

View File

@@ -468,10 +468,9 @@ export function CategoryEvaluationCard({
content={({ active, payload }) => {
if (active && payload && payload.length) {
const item = payload[0]?.payload
const breedVal = item?.breedVal ?? 0
const regionVal = item?.regionVal ?? 0
const farmVal = item?.farmVal ?? 0
const percentile = item?.percentile ?? 50
const epd = item?.epd ?? 0
const regionEpd = (item?.regionVal ?? 0) * (item?.epd / (item?.breedVal || 1)) || 0
const farmEpd = (item?.farmVal ?? 0) * (item?.epd / (item?.breedVal || 1)) || 0
return (
<div className="bg-foreground px-4 py-3 rounded-lg text-sm shadow-lg">
@@ -482,21 +481,21 @@ export function CategoryEvaluationCard({
<span className="w-2 h-2 rounded" style={{ backgroundColor: '#10b981' }}></span>
<span className="text-slate-300"> </span>
</span>
<span className="text-white font-semibold">{regionVal > 0 ? '+' : ''}{regionVal.toFixed(2)}σ</span>
<span className="text-white font-semibold">{regionEpd > 0 ? '+' : ''}{regionEpd.toFixed(2)}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded" style={{ backgroundColor: '#1F3A8F' }}></span>
<span className="text-slate-300"> </span>
</span>
<span className="text-white font-semibold">{farmVal > 0 ? '+' : ''}{farmVal.toFixed(2)}σ</span>
<span className="text-white font-semibold">{farmEpd > 0 ? '+' : ''}{farmEpd.toFixed(2)}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded" style={{ backgroundColor: '#1482B0' }}></span>
<span className="text-white font-medium">{formatCowNoShort(cowNo)} </span>
</span>
<span className="text-white font-bold">{breedVal > 0 ? '+' : ''}{breedVal.toFixed(2)}σ</span>
<span className="text-white font-bold">{epd > 0 ? '+' : ''}{epd.toFixed(2)}</span>
</div>
</div>
</div>

View File

@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useToast } from "@/hooks/use-toast"
import { ComparisonAveragesDto, TraitComparisonAveragesDto, cowApi, genomeApi, geneApi, GeneDetail } from "@/lib/api"
import { ComparisonAveragesDto, TraitComparisonAveragesDto, cowApi, genomeApi, geneApi, GeneDetail, GenomeRequestDto } from "@/lib/api"
import { CowDetail } from "@/types/cow.types"
import { GenomeTrait } from "@/types/genome.types"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
@@ -34,6 +34,7 @@ import { TraitDistributionCharts } from "./genome/_components/trait-distribution
import { TraitComparison } from "./genome/_components/genome-integrated-comparison"
import { CowNumberDisplay } from "@/components/common/cow-number-display"
import { isValidGenomeAnalysis, getInvalidReason, getInvalidMessage } from "@/lib/utils/genome-analysis-config"
import { AuthGuard } from "@/components/auth/auth-guard"
// 형질명 → 카테고리 매핑 (한우 35개 형질)
const TRAIT_CATEGORY_MAP: Record<string, string> = {
@@ -156,6 +157,9 @@ export default function CowOverviewPage() {
const [hasGeneData, setHasGeneData] = useState(false)
const [hasReproductionData, setHasReproductionData] = useState(false)
// 분석 의뢰 정보 (친자감별 결과 포함)
const [genomeRequest, setGenomeRequest] = useState<GenomeRequestDto | null>(null)
// 선발지수 상태
const [selectionIndex, setSelectionIndex] = useState<{
score: number | null;
@@ -259,6 +263,15 @@ export default function CowOverviewPage() {
const genomeExists = genomeDataResult.length > 0 && !!genomeDataResult[0].genomeCows && genomeDataResult[0].genomeCows.length > 0
setHasGenomeData(genomeExists)
// 분석 의뢰 정보 가져오기 (친자감별 결과 포함)
try {
const requestData = await genomeApi.getRequest(cowNo)
setGenomeRequest(requestData)
} catch (reqErr) {
console.error('분석 의뢰 정보 조회 실패:', reqErr)
setGenomeRequest(null)
}
// 유전자(SNP) 데이터 가져오기
try {
const geneDataResult = await geneApi.findByCowId(cowNo)
@@ -305,11 +318,11 @@ export default function CowOverviewPage() {
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
]
// 필터가 활성화되고 형질이 선택되어 있으면 가중치 조건 생성 (대시보드와 동일 로직)
// 필터가 활성화되고 형질이 선택되어 있으면 가중치 조건 생성 (리스트와 동일 로직)
const finalConditions = filters.isActive && filters.selectedTraits && filters.selectedTraits.length > 0
? filters.selectedTraits.map(traitNm => ({
traitNm,
weight: (filters.traitWeights as Record<string, number>)[traitNm] || 1
weight: ((filters.traitWeights as Record<string, number>)[traitNm] || 100) / 100 // 0-100 → 0-1로 정규화
}))
: ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 }))
@@ -463,6 +476,7 @@ export default function CowOverviewPage() {
}
return (
<AuthGuard>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
@@ -625,7 +639,7 @@ export default function CowOverviewPage() {
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
<span className="text-2xl font-bold text-foreground break-all">
{cow?.sireKpn || '-'}
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</span>
{(() => {
const chipSireName = genomeData[0]?.request?.chipSireName
@@ -658,8 +672,8 @@ export default function CowOverviewPage() {
<span className="text-base font-semibold text-muted-foreground"> </span>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
{cow?.damCowId ? (
<CowNumberDisplay cowId={cow?.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
{cow?.damCowId && cow.damCowId !== '0' ? (
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
) : (
<span className="text-2xl font-bold text-foreground">-</span>
)}
@@ -700,7 +714,7 @@ export default function CowOverviewPage() {
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> KPN번호</span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
<span className="text-base font-bold text-foreground break-all">
{cow?.sireKpn || '-'}
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</span>
{(() => {
const chipSireName = genomeData[0]?.request?.chipSireName
@@ -731,8 +745,8 @@ export default function CowOverviewPage() {
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> </span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
{cow?.damCowId ? (
<CowNumberDisplay cowId={cow?.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
{cow?.damCowId && cow.damCowId !== '0' ? (
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
) : (
<span className="text-base font-bold text-foreground">-</span>
)}
@@ -842,7 +856,7 @@ export default function CowOverviewPage() {
<Card className="bg-white border border-border rounded-xl overflow-hidden">
<CardContent className="p-0">
<div className="grid grid-cols-2 sm:grid-cols-4 divide-x divide-y sm:divide-y-0 divide-border">
<div className="grid grid-cols-3 divide-x divide-border">
<div className="p-4">
<div className="text-xs font-medium text-muted-foreground mb-1"></div>
<div className="text-sm font-semibold text-foreground truncate">
@@ -865,12 +879,6 @@ export default function CowOverviewPage() {
{genomeData[0]?.request?.chipType || '-'}
</div>
</div>
<div className="p-4">
<div className="text-xs font-medium text-muted-foreground mb-1"> </div>
<div className="text-sm font-semibold text-foreground truncate">
{genomeData[0]?.request?.chipNo || '-'}
</div>
</div>
</div>
</CardContent>
</Card>
@@ -910,15 +918,253 @@ export default function CowOverviewPage() {
)}
</>
) : (
<Card className="bg-slate-50 border border-border rounded-2xl">
<CardContent className="p-8 text-center">
<BarChart3 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold text-foreground mb-2"> </h3>
<p className="text-sm text-muted-foreground">
.
</p>
</CardContent>
</Card>
<>
{/* 개체 정보 섹션 */}
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3>
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0">
{/* 모바일: 세로 리스트 / 데스크탑: 가로 그리드 */}
<div className="hidden lg:grid lg:grid-cols-4 divide-x divide-border">
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
</div>
<div className="px-5 py-4">
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-2xl font-bold text-foreground" />
</div>
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
</div>
<div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground">
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
</span>
</div>
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"></span>
</div>
<div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
</div>
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"> </span>
</div>
<div className="px-5 py-4">
<span className="text-2xl font-bold text-foreground">-</span>
</div>
</div>
</div>
{/* 모바일: 좌우 배치 리스트 */}
<div className="lg:hidden divide-y divide-border">
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<div className="flex-1 px-4 py-3.5">
<CowNumberDisplay cowId={cowNo} variant="highlight" className="text-base font-bold text-foreground" />
</div>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
{cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
</span>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
{cow?.cowBirthDt
? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월`
: '-'}
</span>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"></span>
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">-</span>
</div>
</div>
</CardContent>
</Card>
{/* 친자확인 섹션 */}
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3>
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0">
{/* 데스크탑: 가로 그리드 */}
<div className="hidden lg:grid lg:grid-cols-2 divide-x divide-border">
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"> KPN번호</span>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
<span className="text-2xl font-bold text-foreground">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
{(() => {
const chipSireName = genomeRequest?.chipSireName
if (chipSireName === '일치') {
return (
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<CheckCircle2 className="w-4 h-4" />
<span></span>
</span>
)
} else if (chipSireName && chipSireName !== '일치') {
return (
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<XCircle className="w-4 h-4" />
<span></span>
</span>
)
} else {
return (
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<span></span>
</span>
)
}
})()}
</div>
</div>
<div>
<div className="bg-muted/50 px-5 py-3 border-b border-border">
<span className="text-base font-semibold text-muted-foreground"> </span>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
{cow?.damCowId && cow.damCowId !== '0' ? (
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
) : (
<span className="text-2xl font-bold text-foreground">-</span>
)}
{(() => {
const chipDamName = genomeRequest?.chipDamName
if (chipDamName === '일치') {
return (
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<CheckCircle2 className="w-4 h-4" />
<span></span>
</span>
)
} else if (chipDamName === '불일치') {
return (
<span className="flex items-center gap-1.5 bg-red-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<XCircle className="w-4 h-4" />
<span></span>
</span>
)
} else if (chipDamName === '이력제부재') {
return (
<span className="flex items-center gap-1.5 bg-amber-500 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
<XCircle className="w-4 h-4" />
<span></span>
</span>
)
} else {
// 정보없음(null/undefined)일 때는 배지 표시 안함
return null
}
})()}
</div>
</div>
</div>
{/* 모바일: 세로 리스트 */}
<div className="lg:hidden divide-y divide-border">
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> KPN</span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
<span className="text-base font-bold text-foreground">{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
{(() => {
const chipSireName = genomeRequest?.chipSireName
if (chipSireName === '일치') {
return (
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<CheckCircle2 className="w-3 h-3" />
<span></span>
</span>
)
} else if (chipSireName && chipSireName !== '일치') {
return (
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<XCircle className="w-3 h-3" />
<span></span>
</span>
)
} else {
return (
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<span></span>
</span>
)
}
})()}
</div>
</div>
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> </span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
{cow?.damCowId && cow.damCowId !== '0' ? (
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
) : (
<span className="text-base font-bold text-foreground">-</span>
)}
{(() => {
const chipDamName = genomeRequest?.chipDamName
if (chipDamName === '일치') {
return (
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<CheckCircle2 className="w-3 h-3" />
<span></span>
</span>
)
} else if (chipDamName === '불일치') {
return (
<span className="flex items-center gap-1 bg-red-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<XCircle className="w-3 h-3" />
<span></span>
</span>
)
} else if (chipDamName === '이력제부재') {
return (
<span className="flex items-center gap-1 bg-amber-500 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
<XCircle className="w-3 h-3" />
<span></span>
</span>
)
} else {
// 정보없음(null/undefined)일 때는 배지 표시 안함
return null
}
})()}
</div>
</div>
</div>
</CardContent>
</Card>
{/* 분석불가 메시지 */}
<Card className="bg-slate-100 border border-slate-300 rounded-2xl">
<CardContent className="p-8 text-center">
<BarChart3 className="h-12 w-12 text-slate-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-slate-700 mb-2">
{genomeRequest ? '유전체 분석 불가' : '유전체 미분석'}
</h3>
<p className="text-sm text-slate-500">
{genomeRequest
? getInvalidMessage(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId)
: '이 개체는 아직 유전체 분석이 진행되지 않았습니다.'
}
</p>
</CardContent>
</Card>
</>
)}
</TabsContent>
@@ -1023,7 +1269,7 @@ export default function CowOverviewPage() {
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
<span className="text-2xl font-bold text-foreground break-all">
{cow?.sireKpn || '-'}
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</span>
{(() => {
const chipSireName = genomeData[0]?.request?.chipSireName
@@ -1056,8 +1302,8 @@ export default function CowOverviewPage() {
<span className="text-base font-semibold text-muted-foreground"> </span>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-3">
{cow?.damCowId ? (
<CowNumberDisplay cowId={cow?.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
{cow?.damCowId && cow.damCowId !== '0' ? (
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-2xl font-bold text-foreground" />
) : (
<span className="text-2xl font-bold text-foreground">-</span>
)}
@@ -1097,7 +1343,7 @@ export default function CowOverviewPage() {
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> KPN번호</span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
<span className="text-base font-bold text-foreground break-all">
{cow?.sireKpn || '-'}
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</span>
{(() => {
const chipSireName = genomeData[0]?.request?.chipSireName
@@ -1128,8 +1374,8 @@ export default function CowOverviewPage() {
<div className="flex items-center">
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground"> </span>
<div className="flex-1 px-4 py-3.5 flex items-center justify-between gap-2">
{cow?.damCowId ? (
<CowNumberDisplay cowId={cow?.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
{cow?.damCowId && cow.damCowId !== '0' ? (
<CowNumberDisplay cowId={cow.damCowId} variant="highlight" className="text-base font-bold text-foreground" />
) : (
<span className="text-base font-bold text-foreground">-</span>
)}
@@ -1220,43 +1466,6 @@ export default function CowOverviewPage() {
</div>
</div>
{/* 유전자형 필터 */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-600 shrink-0">:</span>
<div className="flex bg-slate-100 rounded-lg p-1 gap-1">
<button
onClick={() => setGenotypeFilter('all')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
genotypeFilter === 'all'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
onClick={() => setGenotypeFilter('homozygous')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
genotypeFilter === 'homozygous'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
onClick={() => setGenotypeFilter('heterozygous')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
genotypeFilter === 'heterozygous'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
</div>
</div>
{/* 정렬 드롭다운 */}
<div className="flex items-center gap-2 sm:ml-auto">
<Select
@@ -1545,5 +1754,6 @@ export default function CowOverviewPage() {
</DialogContent>
</Dialog>
</SidebarProvider>
</AuthGuard>
)
}

View File

@@ -15,6 +15,7 @@ import { Activity, AlertCircle, CheckCircle } from "lucide-react"
import { CowNavigation } from "../_components/navigation"
import { useToast } from "@/hooks/use-toast"
import { MPT_REFERENCE_RANGES, isWithinRange } from "@/constants/mpt-reference"
import { AuthGuard } from "@/components/auth/auth-guard"
export default function ReproductionPage() {
const params = useParams()
@@ -150,6 +151,7 @@ export default function ReproductionPage() {
const healthScore = mptItems.length > 0 ? Math.round((normalItems.length / mptItems.length) * 100) : 0
return (
<AuthGuard>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
@@ -256,5 +258,6 @@ export default function ReproductionPage() {
</main>
</SidebarInset>
</SidebarProvider>
</AuthGuard>
)
}

View File

@@ -23,6 +23,7 @@ import { cowApi } from "@/lib/api"
import { useAuthStore } from "@/store/auth-store"
import { useGlobalFilter, GlobalFilterProvider } from "@/contexts/GlobalFilterContext"
import { AnalysisYearProvider } from "@/contexts/AnalysisYearContext"
import { AuthGuard } from "@/components/auth/auth-guard"
/**
* 개체 리스트 페이지
@@ -975,10 +976,10 @@ function MyCowContent() {
{cow.cowSex === "암" ? "암소" : "수소"}
</td>
<td className="cow-table-cell">
{cow.damCowId || '-'}
{cow.damCowId && cow.damCowId !== '0' ? cow.damCowId : '-'}
</td>
<td className="cow-table-cell">
{cow.sireKpn || '-'}
{cow.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
</td>
<td className="cow-table-cell">
{cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', {
@@ -1176,7 +1177,7 @@ function MyCowContent() {
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{cow.damCowId ? (() => {
{cow.damCowId && cow.damCowId !== '0' ? (() => {
const digits = cow.damCowId.replace(/\D/g, '')
if (digits.length === 12) {
return `${digits.slice(0, 3)} ${digits.slice(3, 7)} ${digits.slice(7, 11)} ${digits.slice(11)}`
@@ -1187,7 +1188,7 @@ function MyCowContent() {
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{cow.sireKpn || '-'}</span>
<span className="font-medium">{cow.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
@@ -1379,10 +1380,12 @@ function MyCowContent() {
export default function MyCowPage() {
return (
<GlobalFilterProvider>
<AnalysisYearProvider>
<MyCowContent />
</AnalysisYearProvider>
</GlobalFilterProvider>
<AuthGuard>
<GlobalFilterProvider>
<AnalysisYearProvider>
<MyCowContent />
</AnalysisYearProvider>
</GlobalFilterProvider>
</AuthGuard>
)
}

View File

@@ -30,6 +30,8 @@ import {
XCircle
} from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { AuthGuard } from "@/components/auth/auth-guard"
import { useRouter } from "next/navigation"
import {
Area,
Bar,
@@ -57,6 +59,7 @@ const TRAIT_CATEGORIES: Record<string, string[]> = {
}
export default function DashboardPage() {
const router = useRouter()
const { user } = useAuthStore()
const { filters } = useGlobalFilter()
const [farmNo, setFarmNo] = useState<number | null>(null)
@@ -73,44 +76,52 @@ export default function DashboardPage() {
return () => window.removeEventListener('resize', checkMobile)
}, [])
// 필터에서 고정된 첫 번째 형질 (없으면 '도체중')
const firstPinnedTrait = filters.pinnedTraits?.[0] || '도체중'
// 필터에서 대표 형질: 고정된 형질 중 selectedTraits 순서상 맨 위 > '도체중'
// 고정되지 않은 형질은 순서가 맨 위여도 반영 안 됨
const primaryTrait = (() => {
const pinnedTraits = filters.pinnedTraits || []
const selectedTraits = filters.selectedTraits || []
// selectedTraits 순서대로 순회하면서 고정된 형질 찾기
for (const trait of selectedTraits) {
if (pinnedTraits.includes(trait)) {
return trait
}
}
return '도체중'
})()
// 연도별 육종가 추이 관련 state
const [selectedTrait, setSelectedTrait] = useState<string>(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('dashboard_trait') || firstPinnedTrait
return localStorage.getItem('dashboard_trait') || primaryTrait
}
return firstPinnedTrait
return primaryTrait
})
const [traitTrendData, setTraitTrendData] = useState<YearlyTraitTrendDto | null>(null)
const [traitTrendLoading, setTraitTrendLoading] = useState(false)
// 보은군 내 농가 위치 차트 분포기준 (선발지수 or 개별 형질)
// 필터 활성 시 'overall', 비활성 시 고정된 첫 번째 형질
// 필터 활성 시 'overall', 비활성 시 대표 형질
const [distributionBasis, setDistributionBasis] = useState<string>(() => {
return filters.isActive ? 'overall' : firstPinnedTrait
return filters.isActive ? 'overall' : primaryTrait
})
// 필터 변경 시 기본값 업데이트
useEffect(() => {
if (!filters.isActive && distributionBasis === 'overall') {
setDistributionBasis(firstPinnedTrait)
setDistributionBasis(primaryTrait)
}
}, [filters.isActive, distributionBasis, firstPinnedTrait])
}, [filters.isActive, distributionBasis, primaryTrait])
// 필터에서 고정된 형질이 변경되면 selectedTrait도 업데이트
// 대표 형질(고정 또는 첫 번째)이 변경되면 selectedTrait도 업데이트
useEffect(() => {
if (filters.pinnedTraits && filters.pinnedTraits.length > 0) {
const newFirstPinned = filters.pinnedTraits[0]
// 첫 번째 고정 형질로 변경
setSelectedTrait(newFirstPinned)
// distributionBasis가 overall이 아니면 첫 번째 고정 형질로 변경
if (distributionBasis !== 'overall') {
setDistributionBasis(newFirstPinned)
}
// 대표 형질로 변경
setSelectedTrait(primaryTrait)
// distributionBasis가 overall이 아니면 대표 형질로 변경
if (distributionBasis !== 'overall') {
setDistributionBasis(primaryTrait)
}
}, [filters.pinnedTraits])
}, [primaryTrait])
// 모든 형질 목록 (평탄화)
const allTraits = Object.entries(TRAIT_CATEGORIES).flatMap(([cat, traits]) =>
@@ -356,6 +367,7 @@ export default function DashboardPage() {
}, [farmRanking, stats, distributionBasis])
return (
<AuthGuard>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
@@ -381,7 +393,10 @@ export default function DashboardPage() {
{/* ========== 1. 핵심 KPI 카드 (2개) ========== */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-sm:gap-3">
{/* 총 분석 두수 + 암/수 */}
<div className="bg-gradient-to-br from-primary/5 to-primary/10 rounded-xl border border-primary/20 p-5 pb-5 max-sm:p-4 max-sm:pb-4 shadow-sm hover:shadow-lg hover:border-primary/30 transition-all">
<div
className="bg-gradient-to-br from-primary/5 to-primary/10 rounded-xl border border-primary/20 p-5 pb-5 max-sm:p-4 max-sm:pb-4 shadow-sm hover:shadow-lg hover:border-primary/30 transition-all cursor-pointer"
onClick={() => router.push('/cow')}
>
<div className="flex items-center justify-between mb-2">
<p className="text-base max-sm:text-sm font-semibold text-primary/70"> </p>
<span className="text-xs max-sm:text-[10px] px-2 py-1 rounded-full bg-primary/10 text-primary font-medium">
@@ -1074,6 +1089,30 @@ export default function DashboardPage() {
// 호버된 포인트 인덱스를 위한 로컬 컴포넌트
const RadarChart = () => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
const [clickedIndex, setClickedIndex] = useState<number | null>(null)
// 실제 표시할 인덱스 (클릭된 것 우선, 없으면 호버된 것)
const activeIndex = clickedIndex !== null ? clickedIndex : hoveredIndex
// 클릭/터치 핸들러: 토글 방식
const handleClick = (e: React.MouseEvent | React.TouchEvent, index: number) => {
e.preventDefault()
e.stopPropagation()
setClickedIndex(prev => prev === index ? null : index)
}
// 호버 핸들러: 클릭된 상태가 아닐 때만 동작
const handleMouseEnter = (index: number) => {
if (clickedIndex === null) {
setHoveredIndex(index)
}
}
const handleMouseLeave = () => {
if (clickedIndex === null) {
setHoveredIndex(null)
}
}
return (
<svg width="260" height="290" className="overflow-visible scale-100 sm:scale-100">
@@ -1110,15 +1149,17 @@ export default function DashboardPage() {
{/* 농가 데이터 포인트 */}
{farmPoints.map((p, i) => (
<g key={i} className="cursor-pointer"
onMouseEnter={() => setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)}
onMouseEnter={() => handleMouseEnter(i)}
onMouseLeave={handleMouseLeave}
onClick={(e) => handleClick(e, i)}
onTouchEnd={(e) => handleClick(e, i)}
>
<circle cx={p.x} cy={p.y} r={4}
fill={categoryData[i].avgEpd >= 0 ? '#1F3A8F' : '#ef4444'}
stroke="white" strokeWidth={1.5}
/>
{/* 호버 영역 확대 */}
<circle cx={p.x} cy={p.y} r={15} fill="transparent" />
{/* 호버/터치 영역 확대 */}
<circle cx={p.x} cy={p.y} r={20} fill="transparent" />
</g>
))}
{/* 카테고리 라벨 (클릭/호버 가능) */}
@@ -1130,23 +1171,24 @@ export default function DashboardPage() {
return (
<g key={cat}
className="cursor-pointer"
onMouseEnter={() => setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={() => setHoveredIndex(hoveredIndex === i ? null : i)}
onMouseEnter={() => handleMouseEnter(i)}
onMouseLeave={handleMouseLeave}
onClick={(e) => handleClick(e, i)}
onTouchEnd={(e) => handleClick(e, i)}
>
{/* 호버 영역 확대 */}
{/* 호버/터치 영역 확대 */}
<rect
x={labelX - 25}
y={labelY - 12}
width={50}
height={24}
x={labelX - 30}
y={labelY - 16}
width={60}
height={32}
fill="transparent"
/>
<text
x={labelX}
y={labelY}
textAnchor="middle" dominantBaseline="middle"
className={`text-sm font-bold transition-colors ${hoveredIndex === i ? 'fill-[#1F3A8F]' : 'fill-slate-600'}`}
className={`text-sm font-bold transition-colors ${activeIndex === i ? 'fill-[#1F3A8F]' : 'fill-slate-600'}`}
>
{cat}
</text>
@@ -1154,48 +1196,85 @@ export default function DashboardPage() {
)
})}
{/* 툴팁 - 맨 마지막에 렌더링하여 항상 위에 표시 */}
{hoveredIndex !== null && (() => {
const p = farmPoints[hoveredIndex]
const data = categoryData[hoveredIndex]
const tooltipY = Math.max(140, p.y - 10)
{activeIndex !== null && (() => {
const data = categoryData[activeIndex]
// 라벨 위치 계산 (카테고리 라벨 근처에 툴팁 표시)
const angle = startAngle + activeIndex * angleStep
const labelRadius = maxRadius + 22
const labelX = centerX + labelRadius * Math.cos(angle)
const labelY = centerY + labelRadius * Math.sin(angle)
// 툴팁 위치 조정 (라벨 기준으로 배치)
const tooltipWidth = 160
const tooltipHeight = 130
// 카테고리별 툴팁 위치 최적화
let tooltipX = labelX
let tooltipY = labelY
// 성장 (위쪽) - 아래로
if (activeIndex === 0) {
tooltipY = labelY + 20
}
// 생산 (오른쪽 위) - 왼쪽 아래로
else if (activeIndex === 1) {
tooltipX = labelX - 30
tooltipY = labelY + 10
}
// 체형 (오른쪽 아래) - 왼쪽 위로
else if (activeIndex === 2) {
tooltipX = labelX - 40
tooltipY = labelY - tooltipHeight + 20
}
// 무게 (왼쪽 아래) - 오른쪽 위로
else if (activeIndex === 3) {
tooltipX = labelX + 40
tooltipY = labelY - tooltipHeight + 20
}
// 비율 (왼쪽 위) - 오른쪽 아래로
else if (activeIndex === 4) {
tooltipX = labelX + 30
tooltipY = labelY + 10
}
return (
<g className="pointer-events-none">
{/* 배경 */}
<rect
x={p.x - 90}
y={tooltipY - 135}
width={180}
height={145}
x={tooltipX - tooltipWidth / 2}
y={tooltipY}
width={tooltipWidth}
height={tooltipHeight}
rx={10}
fill="#1e293b"
filter="drop-shadow(0 4px 12px rgba(0,0,0,0.25))"
/>
{/* 카테고리명 + 형질 개수 */}
<text x={p.x} y={tooltipY - 110} textAnchor="middle" fontSize={16} fontWeight={700} fill="#ffffff">
<text x={tooltipX} y={tooltipY + 25} textAnchor="middle" fontSize={16} fontWeight={700} fill="#ffffff">
{data.category}
</text>
<text x={p.x} y={tooltipY - 92} textAnchor="middle" fontSize={11} fill="#94a3b8">
<text x={tooltipX} y={tooltipY + 43} textAnchor="middle" fontSize={11} fill="#94a3b8">
{data.traitCount}
</text>
{/* 구분선 */}
<line x1={p.x - 75} y1={tooltipY - 80} x2={p.x + 75} y2={tooltipY - 80} stroke="#475569" strokeWidth={1} />
<line x1={tooltipX - 65} y1={tooltipY + 55} x2={tooltipX + 65} y2={tooltipY + 55} stroke="#475569" strokeWidth={1} />
{/* 보은군 대비 차이 */}
<text x={p.x} y={tooltipY - 58} textAnchor="middle" fontSize={22} fontWeight={800} fill={data.avgEpd >= 0 ? '#60a5fa' : '#f87171'}>
<text x={tooltipX} y={tooltipY + 77} textAnchor="middle" fontSize={22} fontWeight={800} fill={data.avgEpd >= 0 ? '#60a5fa' : '#f87171'}>
{data.avgEpd >= 0 ? '+' : ''}{data.avgEpd.toFixed(2)}
</text>
<text x={p.x} y={tooltipY - 40} textAnchor="middle" fontSize={11} fill="#94a3b8">
<text x={tooltipX} y={tooltipY + 95} textAnchor="middle" fontSize={11} fill="#94a3b8">
</text>
{/* 순위 표시 */}
<rect
x={p.x - 40}
y={tooltipY - 30}
x={tooltipX - 40}
y={tooltipY + 103}
width={80}
height={24}
rx={12}
height={22}
rx={11}
fill="#16a34a"
/>
<text x={p.x} y={tooltipY - 13} textAnchor="middle" fontSize={13} fontWeight={700} fill="#ffffff">
<text x={tooltipX} y={tooltipY + 118} textAnchor="middle" fontSize={12} fontWeight={700} fill="#ffffff">
{data.avgPercentile}%
</text>
</g>
@@ -1295,5 +1374,6 @@ export default function DashboardPage() {
</div>
</SidebarInset>
</SidebarProvider>
</AuthGuard>
)
}

View File

@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Trophy, ChevronLeft, TrendingUp, Award, Star } from "lucide-react"
import { useRouter } from "next/navigation"
import { AuthGuard } from "@/components/auth/auth-guard"
export default function TopCowsPage() {
const router = useRouter()
@@ -217,6 +218,7 @@ export default function TopCowsPage() {
]
return (
<AuthGuard>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
@@ -448,5 +450,6 @@ export default function TopCowsPage() {
</div>
</SidebarInset>
</SidebarProvider>
</AuthGuard>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAuthStore } from '@/store/auth-store'
interface AuthGuardProps {
children: React.ReactNode
}
/**
* 인증 가드 컴포넌트
* 로그인하지 않은 사용자가 보호된 페이지에 접근하면 로그인 페이지로 리다이렉트
*/
export function AuthGuard({ children }: AuthGuardProps) {
const router = useRouter()
const { isAuthenticated, user } = useAuthStore()
const [isChecking, setIsChecking] = useState(true)
useEffect(() => {
// Zustand hydration 후 체크
const timer = setTimeout(() => {
if (!isAuthenticated || !user) {
router.replace('/login')
} else {
setIsChecking(false)
}
}, 50)
return () => clearTimeout(timer)
}, [isAuthenticated, user, router])
if (isChecking) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent" />
</div>
)
}
return <>{children}</>
}

View File

@@ -38,9 +38,7 @@ export function SiteHeader() {
// 활성화된 필터 개수 계산
const geneCount = filters.selectedGenes.length;
const traitCount = filters.traitWeights
? Object.values(filters.traitWeights).filter(weight => weight > 0).length
: 0;
const traitCount = filters.selectedTraits?.length || 0;
return (
<>

View File

@@ -43,6 +43,24 @@ export const useFilterStore = create<FilterState>()(
partialize: (state) => ({
filters: state.filters,
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as { filters?: GlobalFilterSettings }
// localStorage에 저장된 값이 없거나, selectedTraits가 비어있으면 기본값 적용
if (!persisted?.filters ||
!persisted.filters.selectedTraits ||
persisted.filters.selectedTraits.length === 0) {
return {
...currentState,
filters: DEFAULT_FILTER_SETTINGS,
}
}
return {
...currentState,
filters: persisted.filters,
}
},
}
)
)

View File

@@ -127,7 +127,7 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
analysisIndex: "GENE",
selectedGenes: [],
pinnedGenes: [],
selectedTraits: [],
selectedTraits: ["도체중", "등심단면적", "등지방두께", "근내지방도", "체장", "체고", "흉위"],
pinnedTraits: [],
traitWeights: {
// 성장형질 (점수: 0 ~ 10)
@@ -176,7 +176,7 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
갈비rate: 0,
},
inbreedingThreshold: 0, // 근친도 기본값 0
isActive: false,
isActive: true, // 기본 7개 형질이 선택되어 있으므로 활성화
updtDt: new Date(),
};