INIT
This commit is contained in:
468
frontend/src/app/admin/genome-mapping/page.tsx
Normal file
468
frontend/src/app/admin/genome-mapping/page.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { AdminSidebar } from "@/components/layout/admin-sidebar"
|
||||
import { AdminHeader } from "@/components/layout/admin-header"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { useAuthStore } from "@/store/auth-store"
|
||||
import {
|
||||
IconSearch,
|
||||
IconDownload,
|
||||
IconChartBar,
|
||||
IconUser,
|
||||
IconAlertCircle,
|
||||
IconFileText,
|
||||
IconEye,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
// 농장주별 집계 데이터 타입
|
||||
interface FarmOwnerData {
|
||||
farmOwner: string
|
||||
totalAnimals: number
|
||||
completedAnalysis: number
|
||||
animals: AnimalGenomeData[]
|
||||
}
|
||||
|
||||
interface AnimalGenomeData {
|
||||
animalNo: string
|
||||
sire?: string
|
||||
dam?: string
|
||||
weight12m?: number
|
||||
carcassWeight?: number
|
||||
eyeMuscleArea?: number
|
||||
backfatThickness?: number
|
||||
marblingScore?: number
|
||||
traitsData?: {
|
||||
[key: string]: string | number | null
|
||||
}
|
||||
}
|
||||
|
||||
// FarmOwnerData는 실제 API에서 가져올 예정이므로 MOCK_DATA 제거
|
||||
|
||||
function GenomeMappingContent() {
|
||||
const { user } = useAuthStore()
|
||||
const router = useRouter()
|
||||
const [searchQuery, setSearchQuery] = React.useState("")
|
||||
const [selectedFarmOwner, setSelectedFarmOwner] = React.useState<FarmOwnerData | null>(null)
|
||||
const [detailModalOpen, setDetailModalOpen] = React.useState(false)
|
||||
const [selectedAnimal, setSelectedAnimal] = React.useState<AnimalGenomeData | null>(null)
|
||||
const [farmOwnerData, setFarmOwnerData] = React.useState<FarmOwnerData[]>([])
|
||||
const [isLoading, setIsLoading] = React.useState(true)
|
||||
|
||||
React.useEffect(() => {
|
||||
const isAdmin = user?.userRole === 'ADMIN'
|
||||
if (user && !isAdmin) {
|
||||
alert('관리자만 접근 가능합니다.')
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}, [user, router])
|
||||
|
||||
// 농장주별 집계 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const fetchFarmOwnerData = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/genome/farm-owner-summary`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('데이터 조회에 실패했습니다.')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setFarmOwnerData(data)
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 실패:', error)
|
||||
alert('데이터를 불러오는 중 오류가 발생했습니다.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (user) {
|
||||
fetchFarmOwnerData()
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const isAdmin = user?.userRole === 'ADMIN'
|
||||
if (!user || !isAdmin) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 검색 필터링
|
||||
const filteredData = farmOwnerData.filter((data) =>
|
||||
data.farmOwner.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
// 전체 통계
|
||||
const totalFarmOwners = farmOwnerData.length
|
||||
const totalAnimals = farmOwnerData.reduce((sum, data) => sum + data.totalAnimals, 0)
|
||||
const totalCompleted = farmOwnerData.reduce((sum, data) => sum + data.completedAnalysis, 0)
|
||||
const completionRate = totalAnimals > 0 ? ((totalCompleted / totalAnimals) * 100).toFixed(1) : '0.0'
|
||||
|
||||
const handleViewDetails = (farmOwner: FarmOwnerData) => {
|
||||
setSelectedFarmOwner(farmOwner)
|
||||
}
|
||||
|
||||
const handleViewAnimalDetail = (animal: AnimalGenomeData) => {
|
||||
setSelectedAnimal(animal)
|
||||
setDetailModalOpen(true)
|
||||
}
|
||||
|
||||
const handleExportExcel = () => {
|
||||
// TODO: 엑셀 다운로드 구현
|
||||
alert('엑셀 다운로드 기능은 백엔드 API와 연동 후 구현됩니다.')
|
||||
}
|
||||
|
||||
const handleExportFarmOwnerData = (farmOwner: string) => {
|
||||
// TODO: 농장주별 엑셀 다운로드
|
||||
alert(`${farmOwner}의 데이터를 다운로드합니다.`)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarInset>
|
||||
<AdminHeader
|
||||
breadcrumbs={[
|
||||
{ label: "관리자", href: "/admin" },
|
||||
{ label: "농장주별 집계" }
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 lg:px-6">
|
||||
<div className="rounded-lg p-6 border" style={{ backgroundColor: '#3b82f610' }}>
|
||||
<h1 className="text-2xl font-bold mb-2">농장주별 유전체 데이터 집계</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
업로드된 데이터를 기반으로 농장주별 유전체 분석 결과를 조회합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 전체 통계 */}
|
||||
<div className="px-4 lg:px-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">총 농장주 수</CardTitle>
|
||||
<IconUser className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalFarmOwners}명</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">총 개체 수</CardTitle>
|
||||
<IconChartBar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalAnimals}두</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">분석 완료</CardTitle>
|
||||
<IconFileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalCompleted}두</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">완료율</CardTitle>
|
||||
<IconChartBar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{completionRate}%</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="px-4 lg:px-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<CardTitle>농장주 목록</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1 md:flex-initial">
|
||||
<IconSearch className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="농장주명 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 w-full md:w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleExportExcel} variant="outline">
|
||||
<IconDownload className="h-4 w-4 mr-2" />
|
||||
전체 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" />
|
||||
<p className="mt-4 text-muted-foreground">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>농장주명</TableHead>
|
||||
<TableHead className="text-right">총 개체 수</TableHead>
|
||||
<TableHead className="text-right">분석 완료</TableHead>
|
||||
<TableHead className="text-right">미완료</TableHead>
|
||||
<TableHead className="text-right">완료율</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
<IconAlertCircle className="h-8 w-8 mx-auto mb-2" />
|
||||
<p>{farmOwnerData.length === 0 ? '업로드된 데이터가 없습니다.' : '검색 결과가 없습니다.'}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredData.map((data) => {
|
||||
const pending = data.totalAnimals - data.completedAnalysis
|
||||
const rate = ((data.completedAnalysis / data.totalAnimals) * 100).toFixed(1)
|
||||
return (
|
||||
<TableRow key={data.farmOwner}>
|
||||
<TableCell className="font-medium">{data.farmOwner}</TableCell>
|
||||
<TableCell className="text-right">{data.totalAnimals}두</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="text-green-600 font-semibold">
|
||||
{data.completedAnalysis}두
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className={pending > 0 ? "text-orange-600" : "text-muted-foreground"}>
|
||||
{pending}두
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge
|
||||
variant={parseFloat(rate) >= 90 ? "default" : "secondary"}
|
||||
>
|
||||
{rate}%
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleViewDetails(data)}
|
||||
>
|
||||
<IconEye className="h-4 w-4 mr-1" />
|
||||
상세보기
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleExportFarmOwnerData(data.farmOwner)}
|
||||
>
|
||||
<IconDownload className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 선택된 농장주 상세 정보 */}
|
||||
{selectedFarmOwner && (
|
||||
<div className="px-4 lg:px-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{selectedFarmOwner.farmOwner}님의 개체 목록</CardTitle>
|
||||
<CardDescription>
|
||||
총 {selectedFarmOwner.animals.length}두의 유전체 분석 데이터
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedFarmOwner(null)}
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedFarmOwner.animals.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<IconAlertCircle className="h-8 w-8 mx-auto mb-2" />
|
||||
<p>표시할 개체 데이터가 없습니다.</p>
|
||||
<p className="text-xs mt-1">
|
||||
실제 데이터는 백엔드 API와 연동 후 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>개체번호</TableHead>
|
||||
<TableHead>부</TableHead>
|
||||
<TableHead>모</TableHead>
|
||||
<TableHead className="text-right">12개월령체중</TableHead>
|
||||
<TableHead className="text-right">도체중</TableHead>
|
||||
<TableHead className="text-right">근내지방도</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedFarmOwner.animals.map((animal) => (
|
||||
<TableRow key={animal.animalNo}>
|
||||
<TableCell className="font-medium">
|
||||
{animal.animalNo}
|
||||
</TableCell>
|
||||
<TableCell>{animal.sire || '-'}</TableCell>
|
||||
<TableCell>{animal.dam || '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{animal.weight12m?.toFixed(1) || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{animal.carcassWeight?.toFixed(1) || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{animal.marblingScore?.toFixed(1) || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleViewAnimalDetail(animal)}
|
||||
>
|
||||
<IconEye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 개체 상세 정보 모달 */}
|
||||
<Dialog open={detailModalOpen} onOpenChange={setDetailModalOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>개체 상세 정보</DialogTitle>
|
||||
<DialogDescription>
|
||||
개체번호: {selectedAnimal?.animalNo}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* 기본 정보 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="border rounded-lg p-3">
|
||||
<p className="text-sm text-muted-foreground mb-1">부</p>
|
||||
<p className="font-semibold">{selectedAnimal?.sire || '-'}</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-3">
|
||||
<p className="text-sm text-muted-foreground mb-1">모</p>
|
||||
<p className="font-semibold">{selectedAnimal?.dam || '-'}</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-3">
|
||||
<p className="text-sm text-muted-foreground mb-1">12개월령체중</p>
|
||||
<p className="font-semibold">{selectedAnimal?.weight12m?.toFixed(1) || '-'}</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-3">
|
||||
<p className="text-sm text-muted-foreground mb-1">도체중</p>
|
||||
<p className="font-semibold">{selectedAnimal?.carcassWeight?.toFixed(1) || '-'}</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-3">
|
||||
<p className="text-sm text-muted-foreground mb-1">등심단면적</p>
|
||||
<p className="font-semibold">{selectedAnimal?.eyeMuscleArea?.toFixed(1) || '-'}</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-3">
|
||||
<p className="text-sm text-muted-foreground mb-1">등지방두께</p>
|
||||
<p className="font-semibold">{selectedAnimal?.backfatThickness?.toFixed(1) || '-'}</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-3">
|
||||
<p className="text-sm text-muted-foreground mb-1">근내지방도</p>
|
||||
<p className="font-semibold">{selectedAnimal?.marblingScore?.toFixed(1) || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 전체 형질 데이터 (접을 수 있는 섹션) */}
|
||||
{selectedAnimal?.traitsData && Object.keys(selectedAnimal.traitsData).length > 0 && (
|
||||
<details className="border rounded-lg p-3">
|
||||
<summary className="cursor-pointer font-semibold text-sm">전체 형질 데이터 보기</summary>
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
{Object.entries(selectedAnimal.traitsData).map(([key, value]) => (
|
||||
<div key={key} className="border-l-2 border-blue-500 pl-3 py-1">
|
||||
<p className="text-xs text-muted-foreground mb-1">{key}</p>
|
||||
<p className="text-sm font-medium">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SidebarInset>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GenomeMappingPage() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AdminSidebar />
|
||||
<GenomeMappingContent />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
173
frontend/src/app/admin/page.tsx
Normal file
173
frontend/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { AdminSidebar } from "@/components/layout/admin-sidebar"
|
||||
import { AdminHeader } from "@/components/layout/admin-header"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { useAuthStore } from "@/store/auth-store"
|
||||
import {
|
||||
IconFileUpload,
|
||||
IconUsers,
|
||||
IconChartBar,
|
||||
IconDatabase,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
function AdminDashboardContent() {
|
||||
const { user } = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
// 관리자 권한 체크
|
||||
React.useEffect(() => {
|
||||
const isAdmin = user?.userRole === 'ADMIN'
|
||||
if (user && !isAdmin) {
|
||||
alert('관리자만 접근 가능합니다.')
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}, [user, router])
|
||||
|
||||
const isAdmin = user?.userRole === 'ADMIN'
|
||||
if (!user || !isAdmin) {
|
||||
return null
|
||||
}
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
title: "파일 업로드",
|
||||
description: "유전체 데이터 파일 업로드",
|
||||
icon: IconFileUpload,
|
||||
href: "/admin/upload",
|
||||
color: "bg-blue-500",
|
||||
},
|
||||
{
|
||||
title: "농장주별 집계",
|
||||
description: "농장주별 유전체 데이터 조회",
|
||||
icon: IconChartBar,
|
||||
href: "/admin/genome-mapping",
|
||||
color: "bg-green-500",
|
||||
},
|
||||
{
|
||||
title: "사용자 관리",
|
||||
description: "시스템 사용자 관리",
|
||||
icon: IconUsers,
|
||||
href: "/admin/users",
|
||||
color: "bg-purple-500",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<SidebarInset>
|
||||
<AdminHeader
|
||||
breadcrumbs={[
|
||||
{ label: "관리자 대시보드" }
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 lg:px-6">
|
||||
<div className="rounded-lg p-6 border" style={{ backgroundColor: '#3b82f610' }}>
|
||||
<h1 className="text-2xl font-bold mb-2">관리자 대시보드</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
시스템 관리 및 데이터 관리를 위한 관리자 페이지입니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 빠른 링크 */}
|
||||
<div className="px-4 lg:px-6">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{quickLinks.map((link) => {
|
||||
const Icon = link.icon
|
||||
return (
|
||||
<Card
|
||||
key={link.href}
|
||||
className="cursor-pointer hover:shadow-lg transition-shadow"
|
||||
onClick={() => router.push(link.href)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{link.title}
|
||||
</CardTitle>
|
||||
<div className={`${link.color} p-2 rounded-lg`}>
|
||||
<Icon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{link.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최근 활동 */}
|
||||
<div className="px-4 lg:px-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>시스템 개요</CardTitle>
|
||||
<CardDescription>
|
||||
유전체 데이터 관리 시스템 현황
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
|
||||
<IconDatabase className="h-8 w-8 text-blue-500" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">데이터 파일 관리</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
5가지 유형의 데이터 파일을 업로드하고 관리할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
|
||||
<IconChartBar className="h-8 w-8 text-green-500" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">농장주별 집계</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
업로드된 데이터를 기반으로 농장주별 유전체 분석 결과를 조회할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
|
||||
<IconUsers className="h-8 w-8 text-purple-500" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">사용자 관리</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
시스템 사용자를 관리하고 권한을 설정할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AdminSidebar />
|
||||
<AdminDashboardContent />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
424
frontend/src/app/admin/upload/page.tsx
Normal file
424
frontend/src/app/admin/upload/page.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { AdminSidebar } from "@/components/layout/admin-sidebar"
|
||||
import { AdminHeader } from "@/components/layout/admin-header"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAuthStore } from "@/store/auth-store"
|
||||
import {
|
||||
IconUpload,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconAlertCircle,
|
||||
IconFileText,
|
||||
} from "@tabler/icons-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface FileType {
|
||||
id: string
|
||||
order: number
|
||||
title: string
|
||||
description: string
|
||||
fileType: string
|
||||
exampleFileName: string
|
||||
}
|
||||
|
||||
const FILE_TYPES: FileType[] = [
|
||||
{
|
||||
id: "animal-info",
|
||||
order: 1,
|
||||
title: "① 개체정보",
|
||||
description: "농장주명 + 개체번호 매핑 정보 (필수: 가장 먼저 업로드)",
|
||||
fileType: "유전자", // 백엔드 FileType.GENE
|
||||
exampleFileName: "25년_코쿤_충북한우유전체SNP분석결과_miDNA유전체연구소.xlsx",
|
||||
},
|
||||
{
|
||||
id: "genome-result",
|
||||
order: 2,
|
||||
title: "② 유전능력평가 결과 (DGV)",
|
||||
description: "533두의 유전체 분석 데이터 (CSV 파일)",
|
||||
fileType: "유전체", // 백엔드 FileType.GENOME
|
||||
exampleFileName: "2_20250702)2025_유전체 분석결과(DGV)_...",
|
||||
},
|
||||
{
|
||||
id: "snp-typing",
|
||||
order: 3,
|
||||
title: "③ 개체별 SNP 타이핑 결과",
|
||||
description: "개체별 SNP 유전자형 분석 결과",
|
||||
fileType: "유전자", // 백엔드 FileType.GENE
|
||||
exampleFileName: "KOR002108023350.xlsx",
|
||||
},
|
||||
{
|
||||
id: "mpt-result",
|
||||
order: 4,
|
||||
title: "④ MPT 분석결과",
|
||||
description: "혈액 샘플 분석 결과 (선택사항)",
|
||||
fileType: "혈액대사검사", // 백엔드 FileType.MPT
|
||||
exampleFileName: "■종합혈액화학검사결과서_통합_20250520.xls",
|
||||
},
|
||||
]
|
||||
|
||||
interface UploadState {
|
||||
file: File | null
|
||||
status: "idle" | "uploading" | "success" | "error"
|
||||
message: string
|
||||
isDragging: boolean
|
||||
}
|
||||
|
||||
function FileUploadCard({ fileType }: { fileType: FileType }) {
|
||||
const [state, setState] = React.useState<UploadState>({
|
||||
file: null,
|
||||
status: "idle",
|
||||
message: "",
|
||||
isDragging: false,
|
||||
})
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setState(prev => ({ ...prev, isDragging: true }))
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setState(prev => ({ ...prev, isDragging: false }))
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setState(prev => ({ ...prev, isDragging: false }))
|
||||
|
||||
const files = e.dataTransfer.files
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0]
|
||||
if (isValidExcelFile(file)) {
|
||||
setState(prev => ({ ...prev, file, status: "idle", message: "" }))
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: "error",
|
||||
message: "엑셀/CSV 파일(.xlsx, .xls, .csv)만 업로드 가능합니다.",
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0]
|
||||
if (isValidExcelFile(file)) {
|
||||
setState(prev => ({ ...prev, file, status: "idle", message: "" }))
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: "error",
|
||||
message: "엑셀/CSV 파일(.xlsx, .xls, .csv)만 업로드 가능합니다.",
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isValidExcelFile = (file: File): boolean => {
|
||||
const validExtensions = [
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-excel",
|
||||
"text/csv",
|
||||
"application/csv",
|
||||
]
|
||||
return (
|
||||
validExtensions.includes(file.type) ||
|
||||
file.name.endsWith(".xlsx") ||
|
||||
file.name.endsWith(".xls") ||
|
||||
file.name.endsWith(".csv")
|
||||
)
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!state.file) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: "error",
|
||||
message: "파일을 선택해주세요.",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, status: "uploading", message: "" }))
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append("file", state.file)
|
||||
formData.append("fileType", fileType.fileType)
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/uploadfile`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("파일 업로드에 실패했습니다.")
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: "success",
|
||||
message: result.message || "파일이 성공적으로 업로드되었습니다.",
|
||||
}))
|
||||
|
||||
setTimeout(() => {
|
||||
setState({
|
||||
file: null,
|
||||
status: "idle",
|
||||
message: "",
|
||||
isDragging: false,
|
||||
})
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: "error",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "파일 업로드 중 오류가 발생했습니다.",
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setState({
|
||||
file: null,
|
||||
status: "idle",
|
||||
message: "",
|
||||
isDragging: false,
|
||||
})
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 Bytes"
|
||||
const k = 1024
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<IconFileText className="h-5 w-5" />
|
||||
{fileType.title}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{fileType.description}
|
||||
<br />
|
||||
<span className="text-xs mt-1 inline-block">
|
||||
예시: <code className="bg-muted px-1 py-0.5 rounded">{fileType.exampleFileName}</code>
|
||||
</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 파일 업로드 영역 */}
|
||||
<div
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"relative border-2 border-dashed rounded-lg p-6 text-center transition-colors",
|
||||
state.isDragging
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-muted-foreground/25 hover:border-muted-foreground/50",
|
||||
state.file && "border-primary bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
id={`file-upload-${fileType.id}`}
|
||||
/>
|
||||
|
||||
{!state.file ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-center">
|
||||
<IconUpload className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
파일을 드래그하거나 클릭하여 선택하세요
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
지원 형식: .xlsx, .xls, .csv
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
document.getElementById(`file-upload-${fileType.id}`)?.click()
|
||||
}
|
||||
>
|
||||
파일 선택
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-center">
|
||||
<IconCheck className="h-10 w-10 text-green-600" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{state.file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(state.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRemoveFile}
|
||||
>
|
||||
<IconX className="h-4 w-4 mr-1" />
|
||||
파일 제거
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 업로드 상태 메시지 */}
|
||||
{state.message && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-3 rounded-lg text-sm",
|
||||
state.status === "success" &&
|
||||
"bg-green-50 text-green-900 border border-green-200",
|
||||
state.status === "error" &&
|
||||
"bg-red-50 text-red-900 border border-red-200",
|
||||
state.status === "uploading" &&
|
||||
"bg-blue-50 text-blue-900 border border-blue-200"
|
||||
)}
|
||||
>
|
||||
{state.status === "success" && <IconCheck className="h-5 w-5" />}
|
||||
{state.status === "error" && <IconAlertCircle className="h-5 w-5" />}
|
||||
<span>{state.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 버튼 */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
disabled={!state.file || state.status === "uploading"}
|
||||
>
|
||||
{state.status === "uploading" ? (
|
||||
<>
|
||||
<span className="mr-2">업로드 중...</span>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconUpload className="h-4 w-4 mr-2" />
|
||||
업로드
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminUploadContent() {
|
||||
const { user } = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
React.useEffect(() => {
|
||||
const isAdmin = user?.userRole === 'ADMIN'
|
||||
if (user && !isAdmin) {
|
||||
alert('관리자만 접근 가능합니다.')
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}, [user, router])
|
||||
|
||||
const isAdmin = user?.userRole === 'ADMIN'
|
||||
if (!user || !isAdmin) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarInset>
|
||||
<AdminHeader
|
||||
breadcrumbs={[
|
||||
{ label: "관리자", href: "/admin" },
|
||||
{ label: "파일 업로드" }
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 lg:px-6">
|
||||
<div className="rounded-lg p-6 border" style={{ backgroundColor: '#3b82f610' }}>
|
||||
<h1 className="text-2xl font-bold mb-2">파일 업로드</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
유전체 분석 관련 데이터 파일을 업로드하여 시스템에 등록합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 파일 업로드 카드들 - 1열로 배치하여 순서 명확히 */}
|
||||
<div className="px-4 lg:px-6">
|
||||
<div className="grid gap-6 md:grid-cols-1">
|
||||
{FILE_TYPES.sort((a, b) => a.order - b.order).map((fileType) => (
|
||||
<FileUploadCard key={fileType.id} fileType={fileType} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminUploadPage() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AdminSidebar />
|
||||
<AdminUploadContent />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
44
frontend/src/app/cow/[cowNo]/_components/header.tsx
Normal file
44
frontend/src/app/cow/[cowNo]/_components/header.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface CowHeaderProps {
|
||||
from?: string | null
|
||||
}
|
||||
|
||||
export function CowHeader({ from }: CowHeaderProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const handleBack = () => {
|
||||
if (from === 'ranking') {
|
||||
router.push('/ranking')
|
||||
} else if (from === 'list') {
|
||||
router.push('/list')
|
||||
} else {
|
||||
router.push('/cow')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 뒤로가기 버튼 */}
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted -ml-2 gap-1.5"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="text-sm">목록으로</span>
|
||||
</Button>
|
||||
|
||||
{/* 페이지 헤더 카드 */}
|
||||
<div className="rounded-lg p-6 border bg-slate-50">
|
||||
<h1 className="text-2xl font-bold mb-2">개체 상세 정보</h1>
|
||||
<p className="text-sm text-muted-foreground">개체의 기본 정보와 분석 현황을 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
frontend/src/app/cow/[cowNo]/_components/navigation.tsx
Normal file
92
frontend/src/app/cow/[cowNo]/_components/navigation.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Activity, Dna, LineChart, LayoutDashboard } from "lucide-react"
|
||||
|
||||
interface NavigationProps {
|
||||
cowNo: string
|
||||
}
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
label: '개요',
|
||||
href: '',
|
||||
icon: LayoutDashboard,
|
||||
color: 'slate',
|
||||
},
|
||||
{
|
||||
label: '유전체',
|
||||
href: '/genome',
|
||||
icon: LineChart,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
label: '유전자',
|
||||
href: '/genetics',
|
||||
icon: Dna,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
label: '번식능력',
|
||||
href: '/reproduction',
|
||||
icon: Activity,
|
||||
color: 'green',
|
||||
},
|
||||
]
|
||||
|
||||
const colorClasses = {
|
||||
slate: {
|
||||
active: 'border-b-slate-700 text-slate-700',
|
||||
icon: 'text-slate-700',
|
||||
},
|
||||
blue: {
|
||||
active: 'border-b-blue-600 text-blue-600',
|
||||
icon: 'text-blue-600',
|
||||
},
|
||||
purple: {
|
||||
active: 'border-b-purple-600 text-purple-600',
|
||||
icon: 'text-purple-600',
|
||||
},
|
||||
green: {
|
||||
active: 'border-b-green-600 text-green-600',
|
||||
icon: 'text-green-600',
|
||||
},
|
||||
}
|
||||
|
||||
export function CowNavigation({ cowNo }: NavigationProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm sticky top-0 z-10">
|
||||
<nav className="flex gap-1 p-1.5 overflow-x-auto scrollbar-hide">
|
||||
{navigationItems.map((item) => {
|
||||
const href = `/cow/${cowNo}${item.href}`
|
||||
const isActive = item.href === ''
|
||||
? pathname === `/cow/${cowNo}`
|
||||
: pathname.startsWith(href)
|
||||
|
||||
const Icon = item.icon
|
||||
const colors = colorClasses[item.color as keyof typeof colorClasses]
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex-shrink-0 flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-all duration-200 rounded-t-lg border-b-2",
|
||||
isActive
|
||||
? colors.active
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("w-4 h-4", isActive && colors.icon)} />
|
||||
<span className="whitespace-nowrap">{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer"
|
||||
import { ComparisonAveragesDto, FarmTraitComparisonDto, TraitComparisonAveragesDto } from "@/lib/api"
|
||||
import { Pencil, X, RotateCcw } from 'lucide-react'
|
||||
import {
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
PolarRadiusAxis,
|
||||
Radar,
|
||||
RadarChart,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer
|
||||
} from 'recharts'
|
||||
import { useMediaQuery } from "@/hooks/use-media-query"
|
||||
|
||||
// 디폴트로 표시할 주요 형질 목록
|
||||
const DEFAULT_TRAITS = ['도체중', '등심단면적', '등지방두께', '근내지방도', '체장', '체고', '등심weight']
|
||||
|
||||
// 전체 형질 목록 (35개)
|
||||
const ALL_TRAITS = [
|
||||
// 성장형질 (1개)
|
||||
'12개월령체중',
|
||||
// 경제형질 (4개)
|
||||
'도체중', '등심단면적', '등지방두께', '근내지방도',
|
||||
// 체형형질 (10개)
|
||||
'체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
||||
// 부위별무게 (10개)
|
||||
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
||||
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
||||
// 부위별비율 (10개)
|
||||
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
||||
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
||||
]
|
||||
|
||||
// 형질 카테고리 (백엔드 API와 일치: 성장, 생산, 체형, 무게, 비율)
|
||||
const TRAIT_CATEGORIES: Record<string, string[]> = {
|
||||
'성장': ['12개월령체중'],
|
||||
'생산': ['도체중', '등심단면적', '등지방두께', '근내지방도'],
|
||||
'체형': ['체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위'],
|
||||
'무게': ['안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight', '우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight'],
|
||||
'비율': ['안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate', '우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate'],
|
||||
}
|
||||
|
||||
// 형질명 표시 (전체 이름)
|
||||
const TRAIT_SHORT_NAMES: Record<string, string> = {
|
||||
'도체중': '도체중',
|
||||
'등심단면적': '등심단면적',
|
||||
'등지방두께': '등지방두께',
|
||||
'근내지방도': '근내지방도',
|
||||
'체장': '체장',
|
||||
'체고': '체고',
|
||||
'등심weight': '등심중량',
|
||||
'12개월령체중': '12개월령체중',
|
||||
'십자': '십자',
|
||||
'흉심': '흉심',
|
||||
'흉폭': '흉폭',
|
||||
'고장': '고장',
|
||||
'요각폭': '요각폭',
|
||||
'좌골폭': '좌골폭',
|
||||
'곤폭': '곤폭',
|
||||
'흉위': '흉위',
|
||||
'안심weight': '안심무게',
|
||||
'채끝weight': '채끝무게',
|
||||
'목심weight': '목심무게',
|
||||
'앞다리weight': '앞다리무게',
|
||||
'우둔weight': '우둔무게',
|
||||
'설도weight': '설도무게',
|
||||
'사태weight': '사태무게',
|
||||
'양지weight': '양지무게',
|
||||
'갈비weight': '갈비무게',
|
||||
'안심rate': '안심비율',
|
||||
'등심rate': '등심비율',
|
||||
'채끝rate': '채끝비율',
|
||||
'목심rate': '목심비율',
|
||||
'앞다리rate': '앞다리비율',
|
||||
'우둔rate': '우둔비율',
|
||||
'설도rate': '설도비율',
|
||||
'사태rate': '사태비율',
|
||||
'양지rate': '양지비율',
|
||||
'갈비rate': '갈비비율',
|
||||
}
|
||||
|
||||
interface CategoryStat {
|
||||
category: string
|
||||
avgBreedVal: number
|
||||
avgPercentile: number
|
||||
count: number
|
||||
}
|
||||
|
||||
interface TraitData {
|
||||
id: number
|
||||
name: string
|
||||
category: string
|
||||
breedVal: number // 표준화육종가 (σ 단위)
|
||||
percentile: number
|
||||
actualValue: number // EPD (예상후대차이) 원래 값
|
||||
unit: string
|
||||
description: string
|
||||
importance: string
|
||||
}
|
||||
|
||||
interface CategoryEvaluationCardProps {
|
||||
categoryStats: CategoryStat[]
|
||||
comparisonAverages: ComparisonAveragesDto | null
|
||||
traitComparisonAverages?: TraitComparisonAveragesDto | null // 형질별 평균 비교 데이터 (폴리곤 차트용)
|
||||
regionAvgZ: number
|
||||
farmAvgZ: number
|
||||
allTraits?: TraitData[]
|
||||
cowNo?: string
|
||||
traitAverages?: FarmTraitComparisonDto | null // 형질별 평균 비교 데이터 (기존)
|
||||
hideTraitCards?: boolean // 형질 카드 숨김 여부
|
||||
}
|
||||
|
||||
// 개체번호 포맷팅 (뒷 4자리)
|
||||
const formatCowNoShort = (no?: string) => {
|
||||
if (!no) return '개체'
|
||||
const numOnly = no.replace(/^KOR/i, '')
|
||||
return numOnly.slice(-4)
|
||||
}
|
||||
|
||||
// 개체번호 포맷팅 (뒷 4자리 + 개체 - 차트 범례용)
|
||||
const formatCowNo = (no?: string) => {
|
||||
if (!no) return '개체'
|
||||
const numOnly = no.replace(/^KOR/i, '')
|
||||
return `${numOnly.slice(-4)} 개체`
|
||||
}
|
||||
|
||||
export function CategoryEvaluationCard({
|
||||
categoryStats,
|
||||
comparisonAverages,
|
||||
traitComparisonAverages,
|
||||
regionAvgZ,
|
||||
farmAvgZ,
|
||||
allTraits = [],
|
||||
cowNo,
|
||||
traitAverages,
|
||||
hideTraitCards = false,
|
||||
}: CategoryEvaluationCardProps) {
|
||||
// 차트에 표시할 형질 목록 (커스텀 가능)
|
||||
const [chartTraits, setChartTraits] = useState<string[]>(DEFAULT_TRAITS)
|
||||
|
||||
// 형질 추가 모달/드로어 상태
|
||||
const [isTraitSelectorOpen, setIsTraitSelectorOpen] = useState(false)
|
||||
|
||||
// 선택된 형질 (터치/클릭 시 정보 표시용)
|
||||
const [selectedTraitName, setSelectedTraitName] = useState<string | null>(null)
|
||||
|
||||
// 모바일 여부 확인
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)")
|
||||
|
||||
// 형질 제거
|
||||
const removeTrait = (traitName: string) => {
|
||||
if (chartTraits.length > 3) { // 최소 3개는 유지
|
||||
setChartTraits(prev => prev.filter(t => t !== traitName))
|
||||
}
|
||||
}
|
||||
|
||||
// 형질 추가
|
||||
const addTrait = (traitName: string) => {
|
||||
if (chartTraits.length < 7 && !chartTraits.includes(traitName)) { // 최대 7개
|
||||
setChartTraits(prev => [...prev, traitName])
|
||||
}
|
||||
}
|
||||
|
||||
// 기본값으로 초기화
|
||||
const resetToDefault = () => {
|
||||
setChartTraits(DEFAULT_TRAITS)
|
||||
}
|
||||
|
||||
// 폴리곤 차트용 데이터 생성 (선택된 형질 기반) - 보은군, 농가, 이 개체 비교
|
||||
const traitChartData = chartTraits.map(traitName => {
|
||||
const trait = allTraits.find((t: TraitData) => t.name === traitName)
|
||||
|
||||
// 형질별 평균 데이터에서 해당 형질 찾기
|
||||
const traitAvgRegion = traitComparisonAverages?.region?.find(t => t.traitName === traitName)
|
||||
const traitAvgFarm = traitComparisonAverages?.farm?.find(t => t.traitName === traitName)
|
||||
|
||||
// 보은군/농가 형질별 평균 (데이터 없으면 0)
|
||||
const regionTraitAvg = traitAvgRegion?.avgEbv ?? 0
|
||||
const farmTraitAvg = traitAvgFarm?.avgEbv ?? 0
|
||||
|
||||
return {
|
||||
name: traitName,
|
||||
shortName: TRAIT_SHORT_NAMES[traitName] || traitName,
|
||||
breedVal: trait?.breedVal ?? 0, // 이 개체 표준화육종가 (σ)
|
||||
epd: trait?.actualValue ?? 0, // 이 개체 EPD (예상후대차이)
|
||||
regionVal: regionTraitAvg, // 보은군 평균
|
||||
farmVal: farmTraitAvg, // 농가 평균
|
||||
percentile: trait?.percentile ?? 50,
|
||||
category: trait?.category ?? '체형',
|
||||
diff: trait?.breedVal ?? 0,
|
||||
hasData: !!trait
|
||||
}
|
||||
})
|
||||
|
||||
// 가장 높은 형질 찾기 (이 개체 기준)
|
||||
const bestTraitName = traitChartData.reduce((best, current) =>
|
||||
current.breedVal > best.breedVal ? current : best
|
||||
, traitChartData[0])?.shortName
|
||||
|
||||
// 동적 스케일 계산 (모든 값의 최대 절대값 기준)
|
||||
const allValues = traitChartData.flatMap(d => [d.breedVal, d.regionVal, d.farmVal])
|
||||
const maxAbsValue = Math.max(...allValues.map(Math.abs), 0.3) // 최소 0.3
|
||||
const dynamicDomain = Math.ceil(maxAbsValue * 1.2 * 10) / 10 // 20% 여유
|
||||
|
||||
// 형질 이름으로 원본 형질명 찾기 (shortName -> name)
|
||||
const findTraitNameByShortName = (shortName: string) => {
|
||||
const entry = Object.entries(TRAIT_SHORT_NAMES).find(([, short]) => short === shortName)
|
||||
return entry ? entry[0] : shortName
|
||||
}
|
||||
|
||||
// 커스텀 Tick 컴포넌트 (가장 좋은 형질에 배경색 + 클릭 가능)
|
||||
const CustomAngleTick = ({ x, y, payload }: { x: number; y: number; payload: { value: string } }) => {
|
||||
const isBest = payload.value === bestTraitName
|
||||
const isSelected = selectedTraitName === findTraitNameByShortName(payload.value)
|
||||
const textWidth = payload.value.length * 11 + 20
|
||||
const textHeight = 28
|
||||
|
||||
const handleClick = () => {
|
||||
const traitName = findTraitNameByShortName(payload.value)
|
||||
setSelectedTraitName(prev => prev === traitName ? null : traitName)
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${x},${y})`}
|
||||
onClick={handleClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{(isBest || isSelected) && (
|
||||
<rect
|
||||
x={-textWidth / 2}
|
||||
y={-textHeight / 2}
|
||||
width={textWidth}
|
||||
height={textHeight}
|
||||
rx={6}
|
||||
fill={isSelected ? '#1F3A8F' : '#1482B0'}
|
||||
/>
|
||||
)}
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
dy={5}
|
||||
textAnchor="middle"
|
||||
fontSize={15}
|
||||
fontWeight={(isBest || isSelected) ? 700 : 600}
|
||||
fill={(isBest || isSelected) ? '#ffffff' : '#334155'}
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
// 형질 선택 UI 컴포넌트
|
||||
const TraitSelectorContent = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
형질을 선택하세요 (최소 3개, 최대 7개)
|
||||
</p>
|
||||
<span className="text-sm font-medium text-primary">{chartTraits.length}/7</span>
|
||||
</div>
|
||||
|
||||
{Object.entries(TRAIT_CATEGORIES).map(([category, traits]) => (
|
||||
<div key={category} className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">{category}</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{traits.map(trait => {
|
||||
const isSelected = chartTraits.includes(trait)
|
||||
const traitData = allTraits.find((t: TraitData) => t.name === trait)
|
||||
return (
|
||||
<button
|
||||
key={trait}
|
||||
onClick={() => isSelected ? removeTrait(trait) : addTrait(trait)}
|
||||
disabled={!isSelected && chartTraits.length >= 7}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all ${isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-50'
|
||||
}`}
|
||||
>
|
||||
{TRAIT_SHORT_NAMES[trait] || trait}
|
||||
{traitData && (
|
||||
<span className={`ml-1 text-xs ${isSelected ? 'text-primary-foreground/80' : 'text-muted-foreground'}`}>
|
||||
({traitData.breedVal > 0 ? '+' : ''}{traitData.breedVal.toFixed(1)})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-4 border-t border-border space-y-2">
|
||||
<Button
|
||||
onClick={() => setIsTraitSelectorOpen(false)}
|
||||
className="w-full"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetToDefault}
|
||||
className="w-full"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
기본값으로 초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// 카테고리별 차트 데이터 (향후 사용 예정)
|
||||
void categoryStats.map(stat => {
|
||||
const regionCat = comparisonAverages?.region?.find(c => c.category === stat.category)
|
||||
const farmCat = comparisonAverages?.farm?.find(c => c.category === stat.category)
|
||||
return {
|
||||
...stat,
|
||||
nationwide: 0,
|
||||
region: regionCat?.avgEbv ?? regionAvgZ,
|
||||
farm: farmCat?.avgEbv ?? farmAvgZ,
|
||||
cow: stat.avgBreedVal,
|
||||
diff: stat.avgBreedVal - (regionCat?.avgEbv ?? regionAvgZ)
|
||||
}
|
||||
})
|
||||
|
||||
// 평균 차이 계산 (주요 형질 기준) - 향후 사용 예정
|
||||
const validTraitDiffs = traitChartData.filter(d => !isNaN(d.breedVal))
|
||||
void (validTraitDiffs.length > 0
|
||||
? validTraitDiffs.reduce((sum, d) => sum + d.breedVal, 0) / validTraitDiffs.length
|
||||
: 0)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
{/* 2열 레이아웃: 왼쪽 차트 + 오른쪽 카드 */}
|
||||
<div className="p-4 lg:p-6">
|
||||
{/* 형질 선택 칩 영역 */}
|
||||
<div className="mb-4 lg:mb-6">
|
||||
<div className="flex items-center justify-between mb-2 lg:mb-3">
|
||||
<span className="text-sm lg:text-base font-medium text-muted-foreground">비교 형질을 선택해주세요</span>
|
||||
<button
|
||||
onClick={() => setIsTraitSelectorOpen(true)}
|
||||
className="text-sm lg:text-base text-primary flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-primary/30 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<Pencil className="w-4 h-4 lg:w-5 lg:h-5" />
|
||||
편집
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 lg:gap-2">
|
||||
{chartTraits.map(trait => (
|
||||
<span
|
||||
key={trait}
|
||||
className="inline-flex items-center gap-1 lg:gap-1.5 px-2.5 lg:px-3.5 py-1 lg:py-1.5 bg-primary/10 text-primary rounded-full text-sm lg:text-base font-medium group"
|
||||
>
|
||||
{TRAIT_SHORT_NAMES[trait] || trait}
|
||||
{chartTraits.length > 3 && (
|
||||
<button
|
||||
onClick={() => removeTrait(trait)}
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 rounded-full hover:bg-primary/20 flex items-center justify-center opacity-60 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-3 h-3 lg:w-4 lg:h-4" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
{/* 형질 편집 모달 - 웹: Dialog, 모바일: Drawer */}
|
||||
{isDesktop ? (
|
||||
<Dialog open={isTraitSelectorOpen} onOpenChange={setIsTraitSelectorOpen}>
|
||||
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>비교 형질 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TraitSelectorContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Drawer open={isTraitSelectorOpen} onOpenChange={setIsTraitSelectorOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>비교 형질 선택</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="px-4 pb-6 max-h-[60vh] overflow-y-auto">
|
||||
<TraitSelectorContent />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`flex flex-col ${hideTraitCards ? '' : 'lg:flex-row'} gap-6`}>
|
||||
{/* 폴리곤 차트 */}
|
||||
<div className={hideTraitCards ? 'w-full' : 'lg:w-1/2'}>
|
||||
<div className="bg-muted/20 rounded-xl h-full">
|
||||
<div className={hideTraitCards ? 'h-[95vw] max-h-[520px] sm:h-[420px]' : 'h-[95vw] max-h-[520px] sm:h-[440px]'}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={traitChartData} margin={{ top: 25, right: 30, bottom: 25, left: 30 }}>
|
||||
<PolarGrid
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<PolarAngleAxis
|
||||
dataKey="shortName"
|
||||
tick={<CustomAngleTick x={0} y={0} payload={{ value: '' }} />}
|
||||
tickLine={false}
|
||||
/>
|
||||
<PolarRadiusAxis
|
||||
angle={90}
|
||||
domain={[-dynamicDomain, dynamicDomain]}
|
||||
tick={{ fontSize: 12, fill: '#64748b', fontWeight: 500 }}
|
||||
tickCount={5}
|
||||
axisLine={false}
|
||||
/>
|
||||
{/* 보은군 평균 - Green */}
|
||||
<Radar
|
||||
name="보은군"
|
||||
dataKey="regionVal"
|
||||
stroke="#10b981"
|
||||
fill="#10b981"
|
||||
fillOpacity={0.2}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
{/* 농가 평균 - Navy Blue (중간) */}
|
||||
<Radar
|
||||
name="농가"
|
||||
dataKey="farmVal"
|
||||
stroke="#1F3A8F"
|
||||
fill="#1F3A8F"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2.5}
|
||||
dot={false}
|
||||
/>
|
||||
{/* 이 개체 - Cyan (가장 앞, 강조) */}
|
||||
<Radar
|
||||
name={formatCowNo(cowNo)}
|
||||
dataKey="breedVal"
|
||||
stroke="#1482B0"
|
||||
fill="#1482B0"
|
||||
fillOpacity={0.35}
|
||||
strokeWidth={isDesktop ? 3 : 2}
|
||||
dot={{
|
||||
fill: '#1482B0',
|
||||
strokeWidth: 1,
|
||||
stroke: '#fff',
|
||||
r: isDesktop ? 3 : 2
|
||||
}}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
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
|
||||
|
||||
return (
|
||||
<div className="bg-foreground px-4 py-3 rounded-lg text-sm shadow-lg">
|
||||
<p className="text-white font-bold mb-2">{item?.name}</p>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<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>
|
||||
<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>
|
||||
<span className="text-white font-semibold">{farmVal > 0 ? '+' : ''}{farmVal.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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: '#1482B0' }}></div>
|
||||
<span className="text-base font-semibold text-foreground">{formatCowNoShort(cowNo)} 개체</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 형질 정보 표시 (모바일 친화적) */}
|
||||
{selectedTraitName && (() => {
|
||||
const selectedTrait = traitChartData.find(t => t.name === selectedTraitName)
|
||||
if (!selectedTrait) return null
|
||||
return (
|
||||
<div className="mx-4 mb-4 p-4 bg-slate-50 rounded-xl border border-slate-200 animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-lg font-bold text-foreground">{selectedTrait.shortName}</span>
|
||||
<button
|
||||
onClick={() => setSelectedTraitName(null)}
|
||||
className="text-muted-foreground hover:text-foreground p-1"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded" style={{ backgroundColor: '#10b981' }}></span>
|
||||
<span className="text-sm text-muted-foreground">보은군</span>
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{selectedTrait.regionVal > 0 ? '+' : ''}{selectedTrait.regionVal.toFixed(2)}σ
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded" style={{ backgroundColor: '#1F3A8F' }}></span>
|
||||
<span className="text-sm text-muted-foreground">농가</span>
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{selectedTrait.farmVal > 0 ? '+' : ''}{selectedTrait.farmVal.toFixed(2)}σ
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded" style={{ backgroundColor: '#1482B0' }}></span>
|
||||
<span className="text-sm font-medium text-foreground">{formatCowNoShort(cowNo)} 개체</span>
|
||||
</span>
|
||||
<span className="text-sm font-bold text-primary">
|
||||
{selectedTrait.breedVal > 0 ? '+' : ''}{selectedTrait.breedVal.toFixed(2)}σ
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 7개 형질 카드 (hideTraitCards가 false일 때만 표시) */}
|
||||
{!hideTraitCards && (
|
||||
<div className="lg:w-1/2">
|
||||
<div className="grid grid-cols-2 gap-3 h-full content-start">
|
||||
{traitChartData.map((trait) => {
|
||||
// 카테고리별 배지 색상 (백엔드 API와 일치: 성장, 생산, 체형, 무게, 비율)
|
||||
const categoryColors: Record<string, { bg: string; text: string }> = {
|
||||
'성장': { bg: 'bg-amber-100', text: 'text-amber-700' },
|
||||
'생산': { bg: 'bg-emerald-100', text: 'text-emerald-700' },
|
||||
'체형': { bg: 'bg-blue-100', text: 'text-blue-700' },
|
||||
'무게': { bg: 'bg-purple-100', text: 'text-purple-700' },
|
||||
'비율': { bg: 'bg-rose-100', text: 'text-rose-700' },
|
||||
}
|
||||
const catColor = categoryColors[trait.category] || { bg: 'bg-slate-100', text: 'text-slate-700' }
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trait.name}
|
||||
className="p-3 lg:p-4 rounded-xl border border-border bg-white hover:shadow-md hover:border-primary/30 transition-all duration-200"
|
||||
>
|
||||
{/* 형질명 + 카테고리 배지 */}
|
||||
<div className="flex items-center justify-between mb-2 lg:mb-3">
|
||||
<span className="text-sm lg:text-base font-bold text-foreground">{trait.shortName}</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${catColor.bg} ${catColor.text}`}>
|
||||
{trait.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 카드 개체 표시(4자리 번호) */}
|
||||
<div className="flex items-center justify-between mb-2 lg:mb-3 pb-2 lg:pb-3 border-b border-border">
|
||||
<span className="flex items-center gap-1.5 lg:gap-2">
|
||||
<span className="w-2.5 h-2.5 lg:w-3 lg:h-3 rounded" style={{ backgroundColor: '#1482B0' }}></span>
|
||||
<span className="text-sm lg:text-base text-foreground">{formatCowNoShort(cowNo)} 개체</span>
|
||||
</span>
|
||||
<span className={`font-bold text-lg lg:text-xl ${trait.breedVal >= 0 ? 'text-primary' : 'text-red-500'}`}>
|
||||
{trait.breedVal > 0 ? '+' : ''}{trait.breedVal.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 보은군, 농가 비교 */}
|
||||
<div className="space-y-1.5 lg:space-y-2">
|
||||
<div className="flex items-center justify-between text-sm lg:text-base">
|
||||
<span className="flex items-center gap-1.5 lg:gap-2">
|
||||
<span className="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded" style={{ backgroundColor: '#10b981' }}></span>
|
||||
<span className="text-muted-foreground">보은군</span>
|
||||
</span>
|
||||
<span className="text-foreground font-medium">{trait.regionVal > 0 ? '+' : ''}{trait.regionVal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm lg:text-base">
|
||||
<span className="flex items-center gap-1.5 lg:gap-2">
|
||||
<span className="w-2 h-2 lg:w-2.5 lg:h-2.5 rounded" style={{ backgroundColor: '#1F3A8F' }}></span>
|
||||
<span className="text-muted-foreground">농가</span>
|
||||
</span>
|
||||
<span className="text-foreground font-medium">{trait.farmVal > 0 ? '+' : ''}{trait.farmVal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine
|
||||
} from 'recharts'
|
||||
|
||||
// 형질 데이터 타입
|
||||
interface GenomicTrait {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
breedVal: number
|
||||
percentile: number
|
||||
description: string
|
||||
actualValue: number
|
||||
unit: string
|
||||
}
|
||||
|
||||
interface CategoryTraitGridProps {
|
||||
categories: string[]
|
||||
traits: GenomicTrait[]
|
||||
}
|
||||
|
||||
// 카테고리 색상
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
'성장': '#3b82f6',
|
||||
'생산': '#f59e0b',
|
||||
'체형': '#10b981',
|
||||
'무게': '#8b5cf6',
|
||||
'비율': '#ec4899'
|
||||
}
|
||||
|
||||
// 백분위 기반 등급 계산
|
||||
function getGradeFromPercentile(percentile: number): { grade: string; color: string; bg: string } {
|
||||
if (percentile <= 16) {
|
||||
return { grade: '우수', color: 'text-green-600', bg: 'bg-green-100' }
|
||||
} else if (percentile <= 84) {
|
||||
return { grade: '보통', color: 'text-gray-600', bg: 'bg-gray-100' }
|
||||
} else {
|
||||
return { grade: '개선필요', color: 'text-orange-600', bg: 'bg-orange-100' }
|
||||
}
|
||||
}
|
||||
|
||||
// 정규분포 데이터 생성
|
||||
function generateNormalDistribution(mean: number, stdDev: number, currentValue: number) {
|
||||
const data = []
|
||||
for (let x = -3; x <= 3; x += 0.1) {
|
||||
const y = (1 / (stdDev * Math.sqrt(2 * Math.PI))) *
|
||||
Math.exp(-0.5 * Math.pow((x - mean) / stdDev, 2))
|
||||
data.push({
|
||||
x: parseFloat(x.toFixed(2)),
|
||||
density: y * 100,
|
||||
value: y
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export function CategoryTraitGrid({ categories, traits }: CategoryTraitGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{categories.map(cat => {
|
||||
const catTraits = traits.filter(t => t.category === cat)
|
||||
if (catTraits.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card key={cat} className="bg-white border-0 shadow-sm rounded-xl">
|
||||
<CardHeader className="pb-2 sm:pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-sm md:text-base flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded flex-shrink-0"
|
||||
style={{ backgroundColor: CATEGORY_COLORS[cat] }}
|
||||
/>
|
||||
{cat} 형질
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">{catTraits.length}개 형질 정규분포 분석</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-2 xl:grid-cols-3 gap-2 sm:gap-3">
|
||||
{catTraits.map(trait => {
|
||||
const distribution = generateNormalDistribution(0, 1, trait.breedVal)
|
||||
return (
|
||||
<div key={trait.id} className="border border-border rounded-lg p-2 md:p-3 hover:shadow-md transition-shadow bg-card">
|
||||
{/* 형질명 + 등급 배지 */}
|
||||
<div className="mb-1.5 md:mb-2">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="text-xs md:text-sm font-bold text-foreground truncate" title={trait.name}>
|
||||
{trait.name}
|
||||
</div>
|
||||
<span className={`text-[9px] md:text-[10px] font-medium px-1.5 py-0.5 rounded ${getGradeFromPercentile(trait.percentile).bg} ${getGradeFromPercentile(trait.percentile).color} whitespace-nowrap`}>
|
||||
{getGradeFromPercentile(trait.percentile).grade}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] md:text-xs text-muted-foreground truncate">
|
||||
{trait.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정규분포 미니 차트 */}
|
||||
<div className="h-12 md:h-14 mb-1.5 md:mb-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={distribution} margin={{ top: 3, right: 3, left: 3, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id={`grad-${trait.id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={CATEGORY_COLORS[cat]} stopOpacity={0.5} />
|
||||
<stop offset="95%" stopColor={CATEGORY_COLORS[cat]} stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="x" type="number" domain={[-3, 3]} hide />
|
||||
<YAxis domain={[0, 42]} hide />
|
||||
<RechartsTooltip
|
||||
content={() => (
|
||||
<div className="bg-white/95 backdrop-blur-sm border border-border rounded-lg shadow-lg px-2.5 py-1.5 text-xs">
|
||||
<div className="font-semibold text-foreground">{trait.name}</div>
|
||||
<div className="text-muted-foreground">육종가: {trait.breedVal > 0 ? '+' : ''}{trait.breedVal.toFixed(2)}σ</div>
|
||||
<div className="text-muted-foreground">백분위: 상위 {trait.percentile.toFixed(1)}%</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="density"
|
||||
stroke={CATEGORY_COLORS[cat]}
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#grad-${trait.id})`}
|
||||
/>
|
||||
<ReferenceLine
|
||||
x={parseFloat(trait.breedVal.toFixed(2))}
|
||||
stroke="#ef4444"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 지표 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">표준화 육종가</span>
|
||||
<Badge
|
||||
variant={trait.breedVal > 0 ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{trait.breedVal > 0 ? '+' : ''}{trait.breedVal.toFixed(2)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">백분위</span>
|
||||
<span className="font-semibold text-foreground">{trait.percentile.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={trait.percentile} className="h-1.5" />
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">실측값</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{trait.actualValue} {trait.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
|
||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
||||
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
|
||||
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
||||
|
||||
// 분포 데이터 타입
|
||||
interface DistributionBin {
|
||||
range: string
|
||||
count: number // 보은군 전체 두수
|
||||
farmCount: number // 우리 농가 두수
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
|
||||
// 형질 데이터 타입
|
||||
interface GenomicTrait {
|
||||
id: number
|
||||
name: string
|
||||
category: string
|
||||
breedVal: number
|
||||
percentile: number
|
||||
description: string
|
||||
actualValue: number
|
||||
unit: string
|
||||
}
|
||||
|
||||
interface GenomeIntegratedComparisonProps {
|
||||
farmNo: number | null
|
||||
cowNo?: string
|
||||
// 선발지수 데이터 추가
|
||||
selectionIndex?: {
|
||||
score: number | null
|
||||
percentile: number | null
|
||||
farmRank: number | null
|
||||
farmTotal: number
|
||||
regionRank: number | null
|
||||
regionTotal: number
|
||||
regionName: string | null
|
||||
farmerName: string | null
|
||||
} | null
|
||||
overallScore?: number
|
||||
// 분포 데이터 콜백
|
||||
onDistributionDataChange?: (data: {
|
||||
distributionData: DistributionBin[]
|
||||
totalCowCount: number
|
||||
farmCowCount: number
|
||||
farmAvgScore: number // 우리농장 평균 선발지수
|
||||
regionAvgScore: number // 보은군 평균 선발지수
|
||||
traitComparisons: TraitComparison[] // 형질별 농가/보은군 평균 비교
|
||||
}) => void
|
||||
// 하이라이트 모드 (농가/보은군 비교 클릭 시)
|
||||
highlightMode?: 'farm' | 'region' | null
|
||||
onComparisonClick?: (mode: 'farm' | 'region') => void
|
||||
// 차트 형질 필터 연동
|
||||
chartFilterTrait?: string
|
||||
selectedTraitData?: GenomicTrait[]
|
||||
traitComparisons?: TraitComparison[]
|
||||
}
|
||||
|
||||
|
||||
export interface TraitComparison {
|
||||
trait: string
|
||||
shortName: string
|
||||
myFarm: number
|
||||
region: number
|
||||
diff: number
|
||||
}
|
||||
|
||||
interface IntegratedStats {
|
||||
farmBreedVal: number
|
||||
farmPercentile: number
|
||||
regionBreedVal: number
|
||||
regionPercentile: number
|
||||
difference: number
|
||||
selectedTraitCount: number
|
||||
totalCowCount: number
|
||||
traitComparisons: TraitComparison[]
|
||||
// 농장 순위 관련
|
||||
farmRank: number
|
||||
totalFarmCount: number
|
||||
topPercent: number
|
||||
regionTopPercent: number
|
||||
farmAvgTopPercent: number // 우리 농가 평균 퍼센트
|
||||
}
|
||||
|
||||
// 유전체 종합보고서 보은군 내 농장 순위 가로바 차트
|
||||
export function GenomeIntegratedComparison({
|
||||
farmNo,
|
||||
cowNo,
|
||||
selectionIndex,
|
||||
overallScore = 0,
|
||||
onDistributionDataChange,
|
||||
highlightMode,
|
||||
onComparisonClick,
|
||||
chartFilterTrait = 'overall',
|
||||
selectedTraitData = [],
|
||||
traitComparisons: externalTraitComparisons = []
|
||||
}: GenomeIntegratedComparisonProps) {
|
||||
|
||||
// =======================개체번호 포맷팅: KOR 제외 + 002 1696 8353 8 형식======================
|
||||
// 개체번호 포맷팅 함수 formatCowNo / 유전체 보은 군 내 농장 순위 가로바 차트에서 사용
|
||||
const formatCowNo = (no?: string) => {
|
||||
if (!no) return ''
|
||||
// KOR 제거
|
||||
const numOnly = no.replace(/^KOR/i, '')
|
||||
// 002 1696 8353 8 형식으로 포맷팅
|
||||
if (numOnly.length === 12) {
|
||||
return `${numOnly.slice(0, 3)} ${numOnly.slice(3, 7)} ${numOnly.slice(7, 11)} ${numOnly.slice(11)}`
|
||||
}
|
||||
return numOnly
|
||||
}
|
||||
//===========================================================================================
|
||||
|
||||
const { filters } = useGlobalFilter()
|
||||
const { selectedYear } = useAnalysisYear()
|
||||
const [stats, setStats] = useState<IntegratedStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 형질별 순위 데이터
|
||||
const [traitRank, setTraitRank] = useState<TraitRankDto | null>(null)
|
||||
const [traitRankLoading, setTraitRankLoading] = useState(false)
|
||||
|
||||
// 연도별 추이 데이터
|
||||
const [yearlyTrendData, setYearlyTrendData] = useState<{
|
||||
year: number
|
||||
analyzedCount: number // 분석 두수
|
||||
avgEbv: number // 평균 표준화 육종가
|
||||
}[]>([])
|
||||
const [trendLoading, setTrendLoading] = useState(true)
|
||||
|
||||
// 전체 35개 형질 목록 (filter.types.ts의 traitWeights 키와 동일)
|
||||
const ALL_TRAITS = [
|
||||
// 성장형질 (1개)
|
||||
'12개월령체중',
|
||||
// 경제형질 (4개)
|
||||
'도체중', '등심단면적', '등지방두께', '근내지방도',
|
||||
// 체형형질 (10개)
|
||||
'체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
||||
// 부위별무게 (10개)
|
||||
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
||||
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
||||
// 부위별비율 (10개)
|
||||
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
||||
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
||||
]
|
||||
|
||||
// 형질 조건 생성 (형질명 + 가중치)
|
||||
const getTraitConditions = () => {
|
||||
const selected = Object.entries(filters.traitWeights)
|
||||
.filter(([_, weight]) => weight > 0)
|
||||
.map(([traitNm, weight]) => ({ traitNm, weight }))
|
||||
|
||||
// 선택된 형질이 없으면 전체 35개 형질에 가중치 1 적용
|
||||
if (selected.length === 0) {
|
||||
return ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 }))
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
const traitShortNames: Record<string, string> = {
|
||||
'도체중': '도체중',
|
||||
'근내지방도': '근내지방도',
|
||||
'등심단면적': '등심단면적',
|
||||
'등지방두께': '등지방두께',
|
||||
'12개월령체중': '12개월령체중',
|
||||
// 체형형질
|
||||
'체고': '체고',
|
||||
'십자': '십자',
|
||||
'체장': '체장',
|
||||
'흉심': '흉심',
|
||||
'흉폭': '흉폭',
|
||||
'고장': '고장',
|
||||
'요각폭': '요각폭',
|
||||
'좌골폭': '좌골폭',
|
||||
'곤폭': '곤폭',
|
||||
'흉위': '흉위',
|
||||
// 부위별무게
|
||||
'안심weight': '안심무게',
|
||||
'등심weight': '등심무게',
|
||||
'채끝weight': '채끝무게',
|
||||
'목심weight': '목심무게',
|
||||
'앞다리weight': '앞다리무게',
|
||||
'우둔weight': '우둔무게',
|
||||
'설도weight': '설도무게',
|
||||
'사태weight': '사태무게',
|
||||
'양지weight': '양지무게',
|
||||
'갈비weight': '갈비무게',
|
||||
// 부위별비율
|
||||
'안심rate': '안심비율',
|
||||
'등심rate': '등심비율',
|
||||
'채끝rate': '채끝비율',
|
||||
'목심rate': '목심비율',
|
||||
'앞다리rate': '앞다리비율',
|
||||
'우둔rate': '우둔비율',
|
||||
'설도rate': '설도비율',
|
||||
'사태rate': '사태비율',
|
||||
'양지rate': '양지비율',
|
||||
'갈비rate': '갈비비율',
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchIntegratedStats = async () => {
|
||||
if (!farmNo) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const traitConditions = getTraitConditions()
|
||||
|
||||
// API 2번만 호출 (병렬 처리)
|
||||
const [farmResponse, globalResponse] = await Promise.all([
|
||||
// 1. 내 농장 데이터
|
||||
apiClient.post('/cow/ranking', {
|
||||
filterOptions: { farmNo },
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions
|
||||
}
|
||||
}),
|
||||
// 2. 전체 유저(보은군) 데이터
|
||||
apiClient.post('/cow/ranking/global', {
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
const farmResult = farmResponse.data || farmResponse
|
||||
const globalResult = globalResponse.data || globalResponse
|
||||
|
||||
// 분석완료 개체만 필터링 (sortValue !== null)
|
||||
const farmItems = (farmResult.items || []).filter((item: any) => item.sortValue !== null)
|
||||
const globalItems = (globalResult.items || []).filter((item: any) => item.sortValue !== null)
|
||||
|
||||
if (farmItems.length === 0) {
|
||||
setStats(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 내 농장 평균 (필터 가중치 적용된 선발지수의 평균)
|
||||
const farmScores = farmItems.map((item: any) => item.sortValue || 0)
|
||||
const farmBreedVal = farmScores.reduce((sum: number, s: number) => sum + s, 0) / farmScores.length
|
||||
|
||||
// 전체 유저(보은군) 평균 (필터 가중치 적용된 선발지수의 평균)
|
||||
const globalScores = globalItems.map((item: any) => item.sortValue || 0)
|
||||
const regionBreedVal = globalScores.length > 0
|
||||
? globalScores.reduce((sum: number, s: number) => sum + s, 0) / globalScores.length
|
||||
: 0
|
||||
|
||||
// 형질별 비교 데이터 생성 - 개체별 traits에서 형질별 평균 계산
|
||||
const selectedTraitNames = traitConditions.map(t => t.traitNm)
|
||||
const traitComparisons: TraitComparison[] = selectedTraitNames.map(traitNm => {
|
||||
// 내 농장 형질별 평균
|
||||
// ranking.traits 배열에서 traitName으로 찾아서 traitEbv 값 사용
|
||||
const farmTraitValues = farmItems
|
||||
.map((item: any) => {
|
||||
const traitsArray = item.ranking?.traits || []
|
||||
const trait = traitsArray.find((t: any) => t.traitName === traitNm)
|
||||
return trait?.traitEbv ?? null
|
||||
})
|
||||
.filter((v: any) => v !== null)
|
||||
|
||||
const farmTraitAvg = farmTraitValues.length > 0
|
||||
? farmTraitValues.reduce((sum: number, v: number) => sum + v, 0) / farmTraitValues.length
|
||||
: 0
|
||||
|
||||
// 전체 유저 형질별 평균
|
||||
const globalTraitValues = globalItems
|
||||
.map((item: any) => {
|
||||
const traitsArray = item.ranking?.traits || []
|
||||
const trait = traitsArray.find((t: any) => t.traitName === traitNm)
|
||||
return trait?.traitEbv ?? null
|
||||
})
|
||||
.filter((v: any) => v !== null)
|
||||
|
||||
const globalTraitAvg = globalTraitValues.length > 0
|
||||
? globalTraitValues.reduce((sum: number, v: number) => sum + v, 0) / globalTraitValues.length
|
||||
: 0
|
||||
|
||||
return {
|
||||
trait: traitNm,
|
||||
shortName: traitShortNames[traitNm] || traitNm.slice(0, 4),
|
||||
myFarm: parseFloat(farmTraitAvg.toFixed(2)),
|
||||
region: parseFloat(globalTraitAvg.toFixed(2)),
|
||||
diff: parseFloat((farmTraitAvg - globalTraitAvg).toFixed(2))
|
||||
}
|
||||
})
|
||||
|
||||
// 개체 단위 순위 계산
|
||||
let farmRank = 1
|
||||
let totalFarmCount = 1
|
||||
let topPercent = 50
|
||||
let regionTopPercent = 50
|
||||
let farmAvgTopPercent = 50
|
||||
|
||||
if (globalItems.length > 0) {
|
||||
// 전체 개체 점수 배열 (내림차순 정렬)
|
||||
const allCowScores = globalItems
|
||||
.map((item: any) => item.sortValue || 0)
|
||||
.sort((a: number, b: number) => b - a)
|
||||
|
||||
const totalCowCount = allCowScores.length
|
||||
|
||||
// 농가 평균(farmBreedVal)이 전체 개체 중 상위 몇 %인지 계산
|
||||
const farmAvgRank = allCowScores.filter((score: number) => score > farmBreedVal).length + 1
|
||||
farmAvgTopPercent = Math.round((farmAvgRank / totalCowCount) * 100)
|
||||
|
||||
// 보은군 평균(regionBreedVal)이 전체 개체 중 상위 몇 %인지 계산
|
||||
const regionRank = allCowScores.filter((score: number) => score > regionBreedVal).length + 1
|
||||
regionTopPercent = Math.round((regionRank / totalCowCount) * 100)
|
||||
|
||||
// 농장별 그룹핑 (농장 순위용)
|
||||
const farmScoresMap: Record<number, number[]> = {}
|
||||
globalItems.forEach((item: any) => {
|
||||
const itemFarmNo =
|
||||
item.entity?.farmNo ||
|
||||
item.entity?.farm?.pkFarmNo ||
|
||||
item.entity?.pkFarmNo ||
|
||||
item.farmNo ||
|
||||
item.entity?.fkFarmNo
|
||||
|
||||
if (itemFarmNo) {
|
||||
if (!farmScoresMap[itemFarmNo]) {
|
||||
farmScoresMap[itemFarmNo] = []
|
||||
}
|
||||
farmScoresMap[itemFarmNo].push(item.sortValue || 0)
|
||||
}
|
||||
})
|
||||
|
||||
// 각 농장의 평균 계산 및 정렬
|
||||
const farmAverages = Object.entries(farmScoresMap)
|
||||
.map(([fNo, scores]) => ({
|
||||
farmNo: parseInt(fNo),
|
||||
avg: scores.reduce((sum, s) => sum + s, 0) / scores.length
|
||||
}))
|
||||
.sort((a, b) => b.avg - a.avg)
|
||||
|
||||
totalFarmCount = farmAverages.length || 1
|
||||
const myFarmIndex = farmAverages.findIndex(f => f.farmNo === farmNo)
|
||||
farmRank = myFarmIndex >= 0 ? myFarmIndex + 1 : 1
|
||||
topPercent = Math.round((farmRank / totalFarmCount) * 100)
|
||||
}
|
||||
|
||||
// 분포 데이터 계산 (히스토그램용)
|
||||
if (onDistributionDataChange && globalItems.length > 0) {
|
||||
const bins: DistributionBin[] = [
|
||||
{ range: '-3σ ~ -2.5σ', min: -3, max: -2.5, count: 0, farmCount: 0 },
|
||||
{ range: '-2.5σ ~ -2σ', min: -2.5, max: -2, count: 0, farmCount: 0 },
|
||||
{ range: '-2σ ~ -1.5σ', min: -2, max: -1.5, count: 0, farmCount: 0 },
|
||||
{ range: '-1.5σ ~ -1σ', min: -1.5, max: -1, count: 0, farmCount: 0 },
|
||||
{ range: '-1σ ~ -0.5σ', min: -1, max: -0.5, count: 0, farmCount: 0 },
|
||||
{ range: '-0.5σ ~ 0σ', min: -0.5, max: 0, count: 0, farmCount: 0 },
|
||||
{ range: '0σ ~ 0.5σ', min: 0, max: 0.5, count: 0, farmCount: 0 },
|
||||
{ range: '0.5σ ~ 1σ', min: 0.5, max: 1, count: 0, farmCount: 0 },
|
||||
{ range: '1σ ~ 1.5σ', min: 1, max: 1.5, count: 0, farmCount: 0 },
|
||||
{ range: '1.5σ ~ 2σ', min: 1.5, max: 2, count: 0, farmCount: 0 },
|
||||
{ range: '2σ ~ 2.5σ', min: 2, max: 2.5, count: 0, farmCount: 0 },
|
||||
{ range: '2.5σ ~ 3σ', min: 2.5, max: 3, count: 0, farmCount: 0 },
|
||||
]
|
||||
|
||||
// 전체 개체(보은군)의 선발지수를 구간별로 카운트
|
||||
globalItems.forEach((item: any) => {
|
||||
const score = item.sortValue ?? 0
|
||||
|
||||
// -3 미만은 첫 번째 구간에
|
||||
if (score < -3) {
|
||||
bins[0].count++
|
||||
return
|
||||
}
|
||||
// 3 이상은 마지막 구간에
|
||||
if (score >= 3) {
|
||||
bins[bins.length - 1].count++
|
||||
return
|
||||
}
|
||||
|
||||
// 일반 구간 매칭 (마지막 구간은 >= 포함)
|
||||
for (let i = 0; i < bins.length; i++) {
|
||||
const bin = bins[i]
|
||||
const isLastBin = i === bins.length - 1
|
||||
if (isLastBin) {
|
||||
if (score >= bin.min && score <= bin.max) {
|
||||
bin.count++
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if (score >= bin.min && score < bin.max) {
|
||||
bin.count++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 우리 농가 개체의 선발지수를 구간별로 카운트
|
||||
farmItems.forEach((item: any) => {
|
||||
const score = item.sortValue ?? 0
|
||||
|
||||
// -3 미만은 첫 번째 구간에
|
||||
if (score < -3) {
|
||||
bins[0].farmCount++
|
||||
return
|
||||
}
|
||||
// 3 이상은 마지막 구간에
|
||||
if (score >= 3) {
|
||||
bins[bins.length - 1].farmCount++
|
||||
return
|
||||
}
|
||||
|
||||
// 일반 구간 매칭 (마지막 구간은 >= 포함)
|
||||
for (let i = 0; i < bins.length; i++) {
|
||||
const bin = bins[i]
|
||||
const isLastBin = i === bins.length - 1
|
||||
if (isLastBin) {
|
||||
if (score >= bin.min && score <= bin.max) {
|
||||
bin.farmCount++
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if (score >= bin.min && score < bin.max) {
|
||||
bin.farmCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onDistributionDataChange({
|
||||
distributionData: bins,
|
||||
totalCowCount: selectionIndex?.regionTotal || globalItems.length,
|
||||
farmCowCount: selectionIndex?.farmTotal || farmItems.length,
|
||||
farmAvgScore: farmBreedVal,
|
||||
regionAvgScore: regionBreedVal,
|
||||
traitComparisons
|
||||
})
|
||||
}
|
||||
|
||||
setStats({
|
||||
farmBreedVal: parseFloat(farmBreedVal.toFixed(2)),
|
||||
farmPercentile: normalCdfToPercentile(farmBreedVal),
|
||||
regionBreedVal: parseFloat(regionBreedVal.toFixed(2)),
|
||||
regionPercentile: normalCdfToPercentile(regionBreedVal),
|
||||
difference: parseFloat((farmBreedVal - regionBreedVal).toFixed(2)),
|
||||
selectedTraitCount: traitConditions.length,
|
||||
totalCowCount: farmItems.length,
|
||||
traitComparisons,
|
||||
farmRank,
|
||||
totalFarmCount,
|
||||
topPercent,
|
||||
regionTopPercent,
|
||||
farmAvgTopPercent
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 실패:', error)
|
||||
setStats(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchIntegratedStats()
|
||||
}, [farmNo, filters.traitWeights, selectionIndex?.regionTotal])
|
||||
|
||||
// 연도별 추이 데이터 가져오기
|
||||
useEffect(() => {
|
||||
const fetchYearlyTrend = async () => {
|
||||
if (!farmNo) {
|
||||
setTrendLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setTrendLoading(true)
|
||||
try {
|
||||
const dashboardStats = await genomeApi.getDashboardStats(farmNo)
|
||||
|
||||
// yearlyStats와 yearlyAvgEbv 합치기
|
||||
const yearlyStats = dashboardStats.yearlyStats || []
|
||||
const yearlyAvgEbv = dashboardStats.yearlyAvgEbv || []
|
||||
|
||||
// 연도별 데이터 맵 생성
|
||||
const yearMap = new Map<number, { analyzedCount: number; avgEbv: number }>()
|
||||
|
||||
// yearlyStats에서 분석 두수 가져오기
|
||||
yearlyStats.forEach(stat => {
|
||||
yearMap.set(stat.year, {
|
||||
analyzedCount: stat.analyzedCount || 0,
|
||||
avgEbv: 0
|
||||
})
|
||||
})
|
||||
|
||||
// yearlyAvgEbv에서 평균 육종가 가져오기
|
||||
yearlyAvgEbv.forEach(avg => {
|
||||
if (yearMap.has(avg.year)) {
|
||||
yearMap.get(avg.year)!.avgEbv = avg.farmAvgEbv
|
||||
} else {
|
||||
yearMap.set(avg.year, {
|
||||
analyzedCount: 0,
|
||||
avgEbv: avg.farmAvgEbv
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 배열로 변환하고 연도 오름차순 정렬
|
||||
const trendData = Array.from(yearMap.entries())
|
||||
.map(([year, data]) => ({
|
||||
year,
|
||||
analyzedCount: data.analyzedCount,
|
||||
avgEbv: data.avgEbv
|
||||
}))
|
||||
.sort((a, b) => a.year - b.year)
|
||||
|
||||
setYearlyTrendData(trendData)
|
||||
} catch (error) {
|
||||
console.error('[연도별추이] 데이터 로드 실패:', error)
|
||||
setYearlyTrendData([])
|
||||
} finally {
|
||||
setTrendLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchYearlyTrend()
|
||||
}, [farmNo])
|
||||
|
||||
// 형질별 순위 조회 (형질 필터 변경 시)
|
||||
useEffect(() => {
|
||||
const fetchTraitRank = async () => {
|
||||
// 전체 선발지수 모드면 순위 조회 안 함
|
||||
if (chartFilterTrait === 'overall' || !cowNo) {
|
||||
setTraitRank(null)
|
||||
return
|
||||
}
|
||||
|
||||
setTraitRankLoading(true)
|
||||
try {
|
||||
const rankData = await genomeApi.getTraitRank(cowNo, chartFilterTrait)
|
||||
setTraitRank(rankData)
|
||||
} catch (error) {
|
||||
console.error('[형질순위] 데이터 로드 실패:', error)
|
||||
setTraitRank(null)
|
||||
} finally {
|
||||
setTraitRankLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchTraitRank()
|
||||
}, [chartFilterTrait, cowNo])
|
||||
|
||||
const normalCdfToPercentile = (z: number): number => {
|
||||
const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741
|
||||
const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911
|
||||
const sign = z < 0 ? -1 : 1
|
||||
const absZ = Math.abs(z) / Math.sqrt(2)
|
||||
const t = 1.0 / (1.0 + p * absZ)
|
||||
const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-absZ * absZ)
|
||||
const cdf = 0.5 * (1.0 + sign * y)
|
||||
return Math.round((1 - cdf) * 100)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-border p-6">
|
||||
<div className="flex items-center justify-center h-[260px]">
|
||||
<div className="w-8 h-8 border-2 border-border border-t-primary rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-border p-6">
|
||||
<div className="flex items-center justify-center h-[260px] text-muted-foreground text-base">
|
||||
데이터를 불러올 수 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 형질 필터에 따른 데이터 계산
|
||||
const isTraitMode = chartFilterTrait !== 'overall'
|
||||
|
||||
// 개별 형질 모드일 때 해당 형질의 데이터 찾기
|
||||
const selectedTrait = isTraitMode
|
||||
? selectedTraitData.find(t => t.name === chartFilterTrait)
|
||||
: null
|
||||
|
||||
const traitComparison = isTraitMode
|
||||
? externalTraitComparisons.find(tc => tc.trait === chartFilterTrait)
|
||||
: null
|
||||
|
||||
// 표시할 값 결정
|
||||
const displayScore = isTraitMode && selectedTrait ? selectedTrait.breedVal : overallScore
|
||||
const displayPercentile = isTraitMode && selectedTrait ? selectedTrait.percentile : (selectionIndex?.percentile || 50)
|
||||
// 형질 모드일 때는 API에서 가져온 평균값 사용, 없으면 traitComparison 사용
|
||||
const displayFarmAvg = isTraitMode
|
||||
? (traitRank?.farmAvgEbv ?? traitComparison?.myFarm ?? 0)
|
||||
: stats.farmBreedVal
|
||||
const displayRegionAvg = isTraitMode
|
||||
? (traitRank?.regionAvgEbv ?? traitComparison?.region ?? 0)
|
||||
: stats.regionBreedVal
|
||||
const displayLabel = isTraitMode ? chartFilterTrait : '선발지수'
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-border overflow-hidden">
|
||||
{/* 콘텐츠 */}
|
||||
<div className="p-4 sm:p-5 lg:p-6">
|
||||
<div className="flex flex-col lg:flex-row lg:items-stretch gap-4 lg:gap-6">
|
||||
|
||||
{/* 선발지수/형질 - 타이포 중심 */}
|
||||
<div className="flex-shrink-0 flex flex-col items-center justify-center py-4 lg:py-6 lg:px-8 lg:border-r lg:border-border">
|
||||
<span className="text-sm text-muted-foreground mb-2">{displayLabel}</span>
|
||||
<span className={`text-4xl sm:text-5xl font-black tracking-tight ${displayScore >= 0 ? 'text-primary' : 'text-red-500'}`}>
|
||||
{displayScore > 0 ? '+' : ''}{displayScore.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground mt-2">
|
||||
상위 <span className="font-semibold text-foreground">{displayPercentile.toFixed(0)}%</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 순위 + 평균 대비 */}
|
||||
<div className="flex-1 grid grid-cols-2 gap-3">
|
||||
{/* 농가 내 순위 */}
|
||||
<div className="bg-muted/40 rounded-xl p-3 sm:p-4 flex flex-col justify-center">
|
||||
<span className="text-xs sm:text-sm text-muted-foreground">농가 내 순위</span>
|
||||
<div className="mt-1">
|
||||
{traitRankLoading && isTraitMode ? (
|
||||
<span className="text-xl sm:text-2xl font-bold text-muted-foreground">...</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xl sm:text-2xl font-bold text-foreground">
|
||||
{isTraitMode ? (traitRank?.farmRank || '-') : (selectionIndex?.farmRank || '-')}위
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground ml-1">
|
||||
/ {isTraitMode ? (traitRank?.farmTotal || 0) : (selectionIndex?.farmTotal || 0)}두
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 보은군 내 순위 */}
|
||||
<div className="bg-muted/40 rounded-xl p-3 sm:p-4 flex flex-col justify-center">
|
||||
<span className="text-xs sm:text-sm text-muted-foreground">보은군 내 순위</span>
|
||||
<div className="mt-1">
|
||||
{traitRankLoading && isTraitMode ? (
|
||||
<span className="text-xl sm:text-2xl font-bold text-muted-foreground">...</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xl sm:text-2xl font-bold text-foreground">
|
||||
{isTraitMode ? (traitRank?.regionRank || '-') : (selectionIndex?.regionRank || '-')}위
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground ml-1">
|
||||
/ {isTraitMode ? (traitRank?.regionTotal || 0) : (selectionIndex?.regionTotal || 0)}두
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 농가 평균 대비 - 클릭 가능 */}
|
||||
<button
|
||||
onClick={() => onComparisonClick?.('farm')}
|
||||
className={`rounded-xl p-3 sm:p-4 flex flex-col justify-center text-left transition-all duration-200 ${(displayScore - displayFarmAvg) >= 0 ? 'bg-amber-50' : 'bg-red-50'
|
||||
} ${highlightMode === 'farm'
|
||||
? 'ring-2 ring-amber-500 ring-offset-2 shadow-lg scale-[1.02]'
|
||||
: 'hover:ring-2 hover:ring-amber-300 hover:shadow-md cursor-pointer'
|
||||
} ${isTraitMode ? 'col-span-1' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs sm:text-sm text-muted-foreground">농가평균</span>
|
||||
<span className="text-sm sm:text-base font-semibold text-amber-700">{displayFarmAvg > 0 ? '+' : ''}{displayFarmAvg.toFixed(2)}</span>
|
||||
{highlightMode === 'farm' && (
|
||||
<span className="ml-auto text-[10px] bg-amber-500 text-white px-1.5 py-0.5 rounded-full font-medium">비교중</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-1">
|
||||
<span className={`text-xl sm:text-2xl font-black ${(displayScore - displayFarmAvg) >= 0 ? 'text-amber-600' : 'text-red-500'}`}>
|
||||
농가 대비 {(displayScore - displayFarmAvg) >= 0 ? '+' : ''}{(displayScore - displayFarmAvg).toFixed(2)}
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${(displayScore - displayFarmAvg) >= 0 ? 'text-amber-600' : 'text-red-500'}`}>
|
||||
{(displayScore - displayFarmAvg) >= 0 ? '높음' : '낮음'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground mt-1">클릭하여 분포 확인 →</span>
|
||||
</button>
|
||||
|
||||
{/* 보은군 평균 대비 - 클릭 가능 */}
|
||||
<button
|
||||
onClick={() => onComparisonClick?.('region')}
|
||||
className={`rounded-xl p-3 sm:p-4 flex flex-col justify-center text-left transition-all duration-200 ${(displayScore - displayRegionAvg) >= 0 ? 'bg-blue-50' : 'bg-red-50'
|
||||
} ${highlightMode === 'region'
|
||||
? 'ring-2 ring-blue-500 ring-offset-2 shadow-lg scale-[1.02]'
|
||||
: 'hover:ring-2 hover:ring-blue-300 hover:shadow-md cursor-pointer'
|
||||
} ${isTraitMode ? 'col-span-1' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs sm:text-sm text-muted-foreground">보은군평균</span>
|
||||
<span className="text-sm sm:text-base font-semibold text-blue-700">{displayRegionAvg > 0 ? '+' : ''}{displayRegionAvg.toFixed(2)}</span>
|
||||
{highlightMode === 'region' && (
|
||||
<span className="ml-auto text-[10px] bg-blue-500 text-white px-1.5 py-0.5 rounded-full font-medium">비교중</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-1">
|
||||
<span className={`text-xl sm:text-2xl font-black ${(displayScore - displayRegionAvg) >= 0 ? 'text-blue-600' : 'text-red-500'}`}>
|
||||
보은군 대비 {(displayScore - displayRegionAvg) >= 0 ? '+' : ''}{(displayScore - displayRegionAvg).toFixed(2)}
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${(displayScore - displayRegionAvg) >= 0 ? 'text-blue-600' : 'text-red-500'}`}>
|
||||
{(displayScore - displayRegionAvg) >= 0 ? '높음' : '낮음'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground mt-1">클릭하여 분포 확인 →</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
|
||||
// 기본 7개 형질
|
||||
const DEFAULT_TRAITS = ['도체중', '등심단면적', '등지방두께', '근내지방도', '체장', '체고', '등심weight']
|
||||
|
||||
// 형질명 표시 (전체 이름)
|
||||
const TRAIT_SHORT_NAMES: Record<string, string> = {
|
||||
'도체중': '도체중',
|
||||
'등심단면적': '등심단면적',
|
||||
'등지방두께': '등지방두께',
|
||||
'근내지방도': '근내지방도',
|
||||
'체장': '체장',
|
||||
'체고': '체고',
|
||||
'등심weight': '등심중량'
|
||||
}
|
||||
|
||||
// 카테고리별 배지 스타일 (진한 톤)
|
||||
const CATEGORY_STYLES: Record<string, { bg: string; text: string; border: string }> = {
|
||||
'성장': { bg: 'bg-amber-100', text: 'text-amber-800', border: 'border-amber-300' },
|
||||
'경제': { bg: 'bg-emerald-100', text: 'text-emerald-800', border: 'border-emerald-300' },
|
||||
'체형': { bg: 'bg-blue-100', text: 'text-blue-800', border: 'border-blue-300' },
|
||||
'부위별무게': { bg: 'bg-purple-100', text: 'text-purple-800', border: 'border-purple-300' },
|
||||
'부위별비율': { bg: 'bg-rose-100', text: 'text-rose-800', border: 'border-rose-300' },
|
||||
// 기존 호환
|
||||
'생산': { bg: 'bg-emerald-100', text: 'text-emerald-800', border: 'border-emerald-300' },
|
||||
'무게': { bg: 'bg-purple-100', text: 'text-purple-800', border: 'border-purple-300' },
|
||||
'비율': { bg: 'bg-rose-100', text: 'text-rose-800', border: 'border-rose-300' },
|
||||
}
|
||||
|
||||
interface TraitData {
|
||||
id: number
|
||||
name: string
|
||||
category: string
|
||||
breedVal: number
|
||||
percentile: number
|
||||
actualValue: number
|
||||
unit: string
|
||||
}
|
||||
|
||||
interface TraitDistributionChartsProps {
|
||||
allTraits: TraitData[]
|
||||
regionAvgZ: number
|
||||
farmAvgZ: number
|
||||
cowName?: string
|
||||
totalCowCount?: number
|
||||
selectedTraits?: TraitData[]
|
||||
traitWeights?: Record<string, number>
|
||||
}
|
||||
|
||||
// 리스트 뷰 컴포넌트
|
||||
function TraitListView({ traits, cowName }: { traits: Array<{ name: string; shortName: string; breedVal: number; percentile: number; category?: string; actualValue?: number }>; cowName: string }) {
|
||||
return (
|
||||
<Card className="bg-white border border-border rounded-xl overflow-hidden shadow-md">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-border bg-muted/70">
|
||||
<th className="px-3 sm:px-5 py-4 text-center text-sm sm:text-base font-bold text-foreground">형질명</th>
|
||||
<th className="px-3 sm:px-5 py-4 text-left text-sm sm:text-base font-bold text-foreground">카테고리</th>
|
||||
<th className="px-3 sm:px-5 py-4 text-left text-sm sm:text-base font-bold text-foreground">육종가</th>
|
||||
<th className="px-3 sm:px-5 py-4 text-left text-sm sm:text-base font-bold text-foreground">전국 백분위</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{traits.map((trait, idx) => (
|
||||
<tr key={trait.name} className="border-b border-border last:border-b-0 hover:bg-muted/30 transition-colors">
|
||||
<td className="px-3 sm:px-5 py-4 text-center">
|
||||
<span className="text-sm sm:text-lg font-semibold text-foreground">{trait.shortName}</span>
|
||||
</td>
|
||||
<td className="px-3 sm:px-5 py-4 text-left">
|
||||
{trait.category && (
|
||||
<span
|
||||
className={`inline-flex items-center text-xs sm:text-sm font-bold px-3 sm:px-4 py-1.5 rounded-full whitespace-nowrap border-2 ${CATEGORY_STYLES[trait.category]?.bg || 'bg-slate-50'} ${CATEGORY_STYLES[trait.category]?.text || 'text-slate-700'} ${CATEGORY_STYLES[trait.category]?.border || 'border-slate-200'}`}
|
||||
>
|
||||
{trait.category}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 sm:px-5 py-4 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-base sm:text-xl font-bold ${(trait.actualValue ?? 0) > 0
|
||||
? 'text-green-600'
|
||||
: (trait.actualValue ?? 0) < 0
|
||||
? 'text-red-600'
|
||||
: 'text-muted-foreground'
|
||||
}`}>
|
||||
{trait.actualValue !== undefined ? (
|
||||
<>{trait.actualValue > 0 ? '+' : ''}{trait.actualValue.toFixed(1)}</>
|
||||
) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-5 py-4 text-left">
|
||||
<span className="text-base sm:text-xl font-bold text-foreground">
|
||||
상위 {trait.percentile.toFixed(0)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 메인 컴포넌트
|
||||
export function TraitDistributionCharts({
|
||||
allTraits,
|
||||
regionAvgZ,
|
||||
farmAvgZ,
|
||||
cowName = '개체',
|
||||
totalCowCount = 100,
|
||||
selectedTraits = [],
|
||||
traitWeights = {}
|
||||
}: TraitDistributionChartsProps) {
|
||||
// 개체번호에서 KOR 제외한 002로 시작하는 번호 추출
|
||||
const displayCowNumber = useMemo(() => {
|
||||
if (!cowName) return '개체'
|
||||
const match = cowName.match(/002\d+/)
|
||||
return match ? match[0] : cowName
|
||||
}, [cowName])
|
||||
|
||||
// 표시할 형질 결정: 선택된 형질이 있으면 사용, 없으면 기본 7개
|
||||
// 육종가는 가중치 적용된 값 (원본 육종가 × 가중치)
|
||||
const displayTraits = useMemo(() => {
|
||||
if (selectedTraits.length > 0) {
|
||||
return selectedTraits.map(trait => {
|
||||
const weight = traitWeights[trait.name] || 1
|
||||
return {
|
||||
name: trait.name,
|
||||
shortName: TRAIT_SHORT_NAMES[trait.name] || trait.name,
|
||||
breedVal: trait.breedVal * weight,
|
||||
percentile: trait.percentile,
|
||||
category: trait.category,
|
||||
actualValue: trait.actualValue,
|
||||
hasData: true
|
||||
}
|
||||
})
|
||||
}
|
||||
// 기본 7개 형질
|
||||
return DEFAULT_TRAITS.map(traitName => {
|
||||
const trait = allTraits.find(t => t.name === traitName)
|
||||
const weight = traitWeights[traitName] || 1
|
||||
return {
|
||||
name: traitName,
|
||||
shortName: TRAIT_SHORT_NAMES[traitName] || traitName,
|
||||
breedVal: (trait?.breedVal ?? 0) * weight,
|
||||
percentile: trait?.percentile ?? 50,
|
||||
category: trait?.category,
|
||||
actualValue: trait?.actualValue,
|
||||
hasData: !!trait
|
||||
}
|
||||
})
|
||||
}, [allTraits, selectedTraits, traitWeights])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedTraits.length > 0 ? (
|
||||
<span>선택된 형질 <span className="font-semibold text-primary">{selectedTraits.length}개</span></span>
|
||||
) : (
|
||||
<span>기본 형질 <span className="font-semibold">7개</span></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리스트 뷰 */}
|
||||
<TraitListView traits={displayTraits} cowName={displayCowNumber} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
1079
frontend/src/app/cow/[cowNo]/page.tsx
Normal file
1079
frontend/src/app/cow/[cowNo]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
260
frontend/src/app/cow/[cowNo]/reproduction/page.tsx
Normal file
260
frontend/src/app/cow/[cowNo]/reproduction/page.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation"
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar"
|
||||
import { SiteHeader } from "@/components/layout/site-header"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { cowApi, reproApi } from "@/lib/api"
|
||||
import { CowDetail } from "@/types/cow.types"
|
||||
import { ReproMpt } from "@/types/reprompt.types"
|
||||
import { Activity, AlertCircle, CheckCircle } from "lucide-react"
|
||||
import { CowNavigation } from "../_components/navigation"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { MPT_REFERENCE_RANGES, isWithinRange } from "@/constants/mpt-reference"
|
||||
|
||||
export default function ReproductionPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const cowNo = params.cowNo as string
|
||||
const from = searchParams.get('from')
|
||||
const { toast } = useToast()
|
||||
|
||||
const [cow, setCow] = useState<CowDetail | null>(null)
|
||||
const [reproMpt, setReproMpt] = useState<ReproMpt[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// 개체 기본 정보
|
||||
const cowData = await cowApi.findOne(cowNo)
|
||||
const cowDetail: CowDetail = {
|
||||
...cowData,
|
||||
age: cowData.cowBirthDt
|
||||
? Math.floor((new Date().getTime() - new Date(cowData.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 365))
|
||||
: undefined,
|
||||
}
|
||||
setCow(cowDetail)
|
||||
|
||||
// 암소인 경우만 MPT 정보 조회
|
||||
if (cowData.cowSex === 'F') {
|
||||
try {
|
||||
const mptData = await reproApi.findMptByCowNo(cowNo)
|
||||
setReproMpt(mptData)
|
||||
} catch (err) {
|
||||
console.error('MPT 정보 조회 실패:', err)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('번식 데이터 조회 실패:', err)
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "데이터 로드 실패",
|
||||
description: "번식능력 정보를 불러올 수 없습니다",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [cowNo, toast])
|
||||
|
||||
if (loading || !cow) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// 수소인 경우
|
||||
if (cow.cowSex !== 'F') {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
{/* 탭 네비게이션 */}
|
||||
<div className="max-w-full lg:max-w-7xl xl:max-w-[1600px] 2xl:max-w-[1920px] mx-auto px-4 md:px-6 lg:px-12 xl:px-16 py-3">
|
||||
<CowNavigation cowNo={cowNo} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-full lg:max-w-7xl xl:max-w-[1600px] 2xl:max-w-[1920px] mx-auto space-y-6">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
||||
<Activity className="w-5 h-5 text-foreground" />
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-foreground">번식능력</h2>
|
||||
<p className="text-sm text-muted-foreground">번식 이력 및 MPT 혈액검사 결과</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<AlertCircle className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-sm font-semibold mb-2">수소 개체</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
번식능력 정보는 암소 개체만 확인할 수 있습니다
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// MPT 데이터 정리
|
||||
const mptItems = reproMpt.length > 0 ? [
|
||||
{ name: '글루코스', value: reproMpt[0].bloodSugar, fieldName: 'bloodSugar' },
|
||||
{ name: '콜레스테롤', value: reproMpt[0].cholesterol, fieldName: 'cholesterol' },
|
||||
{ name: 'NEFA', value: reproMpt[0].nefa, fieldName: 'nefa' },
|
||||
{ name: '알부민', value: reproMpt[0].albumin, fieldName: 'albumin' },
|
||||
{ name: '총글로불린', value: reproMpt[0].totalGlobulin, fieldName: 'totalGlobulin' },
|
||||
{ name: 'A/G', value: reproMpt[0].agRatio, fieldName: 'agRatio' },
|
||||
{ name: '요소태질소(BUN)', value: reproMpt[0].bun, fieldName: 'bun' },
|
||||
{ name: 'AST', value: reproMpt[0].ast, fieldName: 'ast' },
|
||||
{ name: 'GGT', value: reproMpt[0].ggt, fieldName: 'ggt' },
|
||||
{ name: '지방간 지수', value: reproMpt[0].fattyLiverIndex, fieldName: 'fattyLiverIndex' },
|
||||
{ name: '칼슘', value: reproMpt[0].calcium, fieldName: 'calcium' },
|
||||
{ name: '인', value: reproMpt[0].phosphorus, fieldName: 'phosphorus' },
|
||||
{ name: '칼슘/인', value: reproMpt[0].caPRatio, fieldName: 'caPRatio' },
|
||||
{ name: '마그네슘', value: reproMpt[0].magnesium, fieldName: 'magnesium' },
|
||||
{ name: '크레아틴', value: reproMpt[0].creatinine, fieldName: 'creatinine' },
|
||||
] : []
|
||||
|
||||
const normalItems = mptItems.filter(item => {
|
||||
if (item.value === undefined || item.value === null) return false
|
||||
return isWithinRange(item.value, item.fieldName) === 'normal'
|
||||
})
|
||||
|
||||
const healthScore = mptItems.length > 0 ? Math.round((normalItems.length / mptItems.length) * 100) : 0
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
{/* 탭 네비게이션 */}
|
||||
<div className="max-w-full lg:max-w-7xl xl:max-w-[1600px] 2xl:max-w-[1920px] mx-auto px-4 md:px-6 lg:px-12 xl:px-16 py-3">
|
||||
<CowNavigation cowNo={cowNo} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-4 pb-12 md:p-6 md:pb-12 lg:px-12 lg:py-6 lg:pb-12 xl:px-16 xl:py-6 xl:pb-12">
|
||||
<div className="max-w-full lg:max-w-7xl xl:max-w-[1600px] 2xl:max-w-[1920px] mx-auto space-y-6">
|
||||
{/* 페이지 타이틀 */}
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
||||
<Activity className="w-5 h-5 text-foreground" />
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-foreground">번식능력</h2>
|
||||
<p className="text-sm text-muted-foreground">번식 이력 및 MPT 혈액검사 결과</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MPT 혈액검사 결과 */}
|
||||
{reproMpt.length > 0 ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>건강 점수</CardTitle>
|
||||
<CardDescription>전체 MPT 항목 대비 정상 비율</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-4xl font-bold">{healthScore}점</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
정상 {normalItems.length}개 / 전체 {mptItems.length}개
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-5xl ${healthScore >= 80 ? 'text-green-500' : healthScore >= 60 ? 'text-yellow-500' : 'text-orange-500'}`}>
|
||||
{healthScore >= 80 ? <CheckCircle /> : <AlertCircle />}
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={healthScore} className="h-3" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>MPT 혈액검사 상세</CardTitle>
|
||||
<CardDescription>
|
||||
검사일: {reproMpt[0].reproMptDate
|
||||
? new Date(reproMpt[0].reproMptDate).toLocaleDateString('ko-KR')
|
||||
: '정보 없음'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{mptItems.map((item, idx) => {
|
||||
const isNormal = item.value !== undefined && item.value !== null && isWithinRange(item.value, item.fieldName) === 'normal'
|
||||
const reference = MPT_REFERENCE_RANGES[item.fieldName as keyof typeof MPT_REFERENCE_RANGES]
|
||||
|
||||
return (
|
||||
<div key={idx} className={`p-3 rounded-lg border ${isNormal ? 'bg-green-50 border-green-200' : 'bg-orange-50 border-orange-200'}`}>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">{item.name}</div>
|
||||
<div className="text-lg font-bold">{item.value?.toFixed(2) || '-'}</div>
|
||||
{reference && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
정상: {reference.lowerLimit} ~ {reference.upperLimit}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1">
|
||||
{isNormal ? (
|
||||
<Badge variant="default" className="text-xs">정상</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">주의</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{reproMpt[0].reproMptNote && (
|
||||
<div className="mt-6 p-4 bg-muted rounded-lg">
|
||||
<div className="text-sm font-semibold mb-2">검사 메모</div>
|
||||
<p className="text-sm text-muted-foreground">{reproMpt[0].reproMptNote}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<AlertCircle className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-sm font-semibold mb-2">MPT 검사 기록 없음</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
혈액검사를 진행하여 건강 상태를 확인하세요
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
1371
frontend/src/app/cow/page.tsx
Normal file
1371
frontend/src/app/cow/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
614
frontend/src/app/dashboard/data.json
Normal file
614
frontend/src/app/dashboard/data.json
Normal file
@@ -0,0 +1,614 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Cover page",
|
||||
"type": "Cover page",
|
||||
"status": "In Process",
|
||||
"target": "18",
|
||||
"limit": "5",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Table of contents",
|
||||
"type": "Table of contents",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "24",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Executive summary",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "10",
|
||||
"limit": "13",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Technical approach",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "27",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Design",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "2",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"header": "Capabilities",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "20",
|
||||
"limit": "8",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"header": "Integration with existing systems",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "21",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"header": "Innovation and Advantages",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "25",
|
||||
"limit": "26",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"header": "Overview of EMR's Innovative Solutions",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "7",
|
||||
"limit": "23",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"header": "Advanced Algorithms and Machine Learning",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "28",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"header": "Adaptive Communication Protocols",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "9",
|
||||
"limit": "31",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"header": "Advantages Over Current Technologies",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "0",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"header": "Past Performance",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "33",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"header": "Customer Feedback and Satisfaction Levels",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "34",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"header": "Implementation Challenges and Solutions",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "3",
|
||||
"limit": "35",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"header": "Security Measures and Data Protection Policies",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "6",
|
||||
"limit": "36",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"header": "Scalability and Future Proofing",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "4",
|
||||
"limit": "37",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"header": "Cost-Benefit Analysis",
|
||||
"type": "Plain language",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "38",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"header": "User Training and Onboarding Experience",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "17",
|
||||
"limit": "39",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"header": "Future Development Roadmap",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "11",
|
||||
"limit": "40",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"header": "System Architecture Overview",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "24",
|
||||
"limit": "18",
|
||||
"reviewer": "Maya Johnson"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"header": "Risk Management Plan",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "22",
|
||||
"reviewer": "Carlos Rodriguez"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"header": "Compliance Documentation",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "31",
|
||||
"limit": "27",
|
||||
"reviewer": "Sarah Chen"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"header": "API Documentation",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "8",
|
||||
"limit": "12",
|
||||
"reviewer": "Raj Patel"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"header": "User Interface Mockups",
|
||||
"type": "Visual",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "25",
|
||||
"reviewer": "Leila Ahmadi"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"header": "Database Schema",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "20",
|
||||
"reviewer": "Thomas Wilson"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"header": "Testing Methodology",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "14",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"header": "Deployment Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "30",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"header": "Budget Breakdown",
|
||||
"type": "Financial",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"header": "Market Analysis",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Sophia Martinez"
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"header": "Competitor Comparison",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"header": "Maintenance Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "23",
|
||||
"reviewer": "Alex Thompson"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"header": "User Personas",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "24",
|
||||
"reviewer": "Nina Patel"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"header": "Accessibility Compliance",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"header": "Performance Metrics",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "David Kim"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"header": "Disaster Recovery Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"header": "Third-party Integrations",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"header": "User Feedback Summary",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "15",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"header": "Localization Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "12",
|
||||
"limit": "19",
|
||||
"reviewer": "Maria Garcia"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"header": "Mobile Compatibility",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "James Wilson"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"header": "Data Migration Plan",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"header": "Quality Assurance Protocols",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Priya Singh"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"header": "Stakeholder Analysis",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "11",
|
||||
"limit": "14",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"header": "Environmental Impact Assessment",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"header": "Intellectual Property Rights",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "20",
|
||||
"reviewer": "Sarah Johnson"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"header": "Customer Support Framework",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"header": "Version Control Strategy",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"header": "Continuous Integration Pipeline",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Michael Chen"
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"header": "Regulatory Compliance",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"header": "User Authentication System",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"header": "Data Analytics Framework",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"header": "Cloud Infrastructure",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"header": "Network Security Measures",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Lisa Wong"
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"header": "Project Timeline",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"header": "Resource Allocation",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 56,
|
||||
"header": "Team Structure and Roles",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"header": "Communication Protocols",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"header": "Success Metrics",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 59,
|
||||
"header": "Internationalization Support",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 60,
|
||||
"header": "Backup and Recovery Procedures",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"header": "Monitoring and Alerting System",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Daniel Park"
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"header": "Code Review Guidelines",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "15",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"header": "Documentation Standards",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"header": "Release Management Process",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"header": "Feature Prioritization Matrix",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Emma Davis"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"header": "Technical Debt Assessment",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"header": "Capacity Planning",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"header": "Service Level Agreements",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Assign reviewer"
|
||||
}
|
||||
]
|
||||
1185
frontend/src/app/dashboard/page.tsx
Normal file
1185
frontend/src/app/dashboard/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
452
frontend/src/app/dashboard/top-cows/page.tsx
Normal file
452
frontend/src/app/dashboard/top-cows/page.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
'use client'
|
||||
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar"
|
||||
import { SiteHeader } from "@/components/layout/site-header"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
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"
|
||||
|
||||
export default function TopCowsPage() {
|
||||
const router = useRouter()
|
||||
|
||||
// 더미 데이터 - 실제로는 백엔드에서 가져와야 함
|
||||
const topCows = [
|
||||
{
|
||||
rank: 1,
|
||||
cowNo: '001122334401',
|
||||
name: 'KOR 001122334401',
|
||||
score: 85.2,
|
||||
grade: 'A',
|
||||
birthDate: '2021-03-15',
|
||||
age: '3년 8개월',
|
||||
lactationCount: 2,
|
||||
traits: {
|
||||
carcassWeight: 92,
|
||||
marbling: 88,
|
||||
eyeMuscleArea: 90,
|
||||
backfatThickness: 82,
|
||||
bodyConformation: 95
|
||||
},
|
||||
strengths: ['체형', '도체중', '등심단면적'],
|
||||
weaknesses: ['등지방두께'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 485,
|
||||
marblingScore: 7,
|
||||
eyeMuscleArea: 95
|
||||
},
|
||||
recommendations: '체형이 매우 우수한 개체입니다. 등지방두께 개선을 위한 KPN 선택을 권장합니다.'
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
cowNo: '001122334402',
|
||||
name: 'KOR 001122334402',
|
||||
score: 82.7,
|
||||
grade: 'A',
|
||||
birthDate: '2020-11-20',
|
||||
age: '4년 0개월',
|
||||
lactationCount: 3,
|
||||
traits: {
|
||||
carcassWeight: 88,
|
||||
marbling: 94,
|
||||
eyeMuscleArea: 86,
|
||||
backfatThickness: 85,
|
||||
bodyConformation: 90
|
||||
},
|
||||
strengths: ['근내지방도', '체형', '도체중'],
|
||||
weaknesses: ['등심단면적'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 465,
|
||||
marblingScore: 8,
|
||||
eyeMuscleArea: 88
|
||||
},
|
||||
recommendations: '근내지방도가 탁월한 개체입니다. 등심단면적 개선을 위한 교배 전략이 필요합니다.'
|
||||
},
|
||||
{
|
||||
rank: 3,
|
||||
cowNo: '001122334403',
|
||||
name: 'KOR 001122334403',
|
||||
score: 79.1,
|
||||
grade: 'A',
|
||||
birthDate: '2021-05-10',
|
||||
age: '3년 6개월',
|
||||
lactationCount: 2,
|
||||
traits: {
|
||||
carcassWeight: 90,
|
||||
marbling: 85,
|
||||
eyeMuscleArea: 88,
|
||||
backfatThickness: 75,
|
||||
bodyConformation: 87
|
||||
},
|
||||
strengths: ['도체중', '등심단면적'],
|
||||
weaknesses: ['등지방두께', '체형'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 495,
|
||||
marblingScore: 6,
|
||||
eyeMuscleArea: 92
|
||||
},
|
||||
recommendations: '도체중이 우수한 개체입니다. 등지방두께와 체형 개선에 집중할 필요가 있습니다.'
|
||||
},
|
||||
{
|
||||
rank: 4,
|
||||
cowNo: '001122334404',
|
||||
name: 'KOR 001122334404',
|
||||
score: 76.5,
|
||||
grade: 'A',
|
||||
birthDate: '2020-08-25',
|
||||
age: '4년 3개월',
|
||||
lactationCount: 3,
|
||||
traits: {
|
||||
carcassWeight: 84,
|
||||
marbling: 82,
|
||||
eyeMuscleArea: 85,
|
||||
backfatThickness: 90,
|
||||
bodyConformation: 88
|
||||
},
|
||||
strengths: ['등지방두께', '체형'],
|
||||
weaknesses: ['근내지방도'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 455,
|
||||
marblingScore: 5,
|
||||
eyeMuscleArea: 87
|
||||
},
|
||||
recommendations: '등지방두께가 매우 우수한 개체입니다. 근내지방도 개선을 위한 KPN 선택이 필요합니다.'
|
||||
},
|
||||
{
|
||||
rank: 5,
|
||||
cowNo: '001122334405',
|
||||
name: 'KOR 001122334405',
|
||||
score: 73.8,
|
||||
grade: 'A',
|
||||
birthDate: '2021-01-18',
|
||||
age: '3년 10개월',
|
||||
lactationCount: 2,
|
||||
traits: {
|
||||
carcassWeight: 86,
|
||||
marbling: 80,
|
||||
eyeMuscleArea: 83,
|
||||
backfatThickness: 88,
|
||||
bodyConformation: 85
|
||||
},
|
||||
strengths: ['등지방두께', '도체중'],
|
||||
weaknesses: ['근내지방도', '등심단면적'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 470,
|
||||
marblingScore: 5,
|
||||
eyeMuscleArea: 85
|
||||
},
|
||||
recommendations: '균형 잡힌 개체입니다. 근내지방도 향상을 위한 교배가 권장됩니다.'
|
||||
},
|
||||
{
|
||||
rank: 6,
|
||||
cowNo: '001122334406',
|
||||
name: 'KOR 001122334406',
|
||||
score: 71.2,
|
||||
grade: 'B',
|
||||
birthDate: '2020-12-05',
|
||||
age: '3년 11개월',
|
||||
lactationCount: 2,
|
||||
traits: {
|
||||
carcassWeight: 82,
|
||||
marbling: 78,
|
||||
eyeMuscleArea: 81,
|
||||
backfatThickness: 84,
|
||||
bodyConformation: 83
|
||||
},
|
||||
strengths: ['등지방두께', '도체중'],
|
||||
weaknesses: ['근내지방도', '체형'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 445,
|
||||
marblingScore: 4,
|
||||
eyeMuscleArea: 82
|
||||
},
|
||||
recommendations: '전반적으로 평균 이상인 개체입니다. 근내지방도와 체형 개선이 필요합니다.'
|
||||
},
|
||||
{
|
||||
rank: 7,
|
||||
cowNo: '001122334407',
|
||||
name: 'KOR 001122334407',
|
||||
score: 68.9,
|
||||
grade: 'B',
|
||||
birthDate: '2021-06-22',
|
||||
age: '3년 5개월',
|
||||
lactationCount: 2,
|
||||
traits: {
|
||||
carcassWeight: 80,
|
||||
marbling: 76,
|
||||
eyeMuscleArea: 79,
|
||||
backfatThickness: 82,
|
||||
bodyConformation: 81
|
||||
},
|
||||
strengths: ['등지방두께'],
|
||||
weaknesses: ['근내지방도', '등심단면적', '체형'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 430,
|
||||
marblingScore: 4,
|
||||
eyeMuscleArea: 80
|
||||
},
|
||||
recommendations: '개선 여지가 많은 개체입니다. 근내지방도와 체형 강화가 필요합니다.'
|
||||
},
|
||||
{
|
||||
rank: 8,
|
||||
cowNo: '001122334408',
|
||||
name: 'KOR 001122334408',
|
||||
score: 65.5,
|
||||
grade: 'B',
|
||||
birthDate: '2020-09-30',
|
||||
age: '4년 2개월',
|
||||
lactationCount: 3,
|
||||
traits: {
|
||||
carcassWeight: 78,
|
||||
marbling: 74,
|
||||
eyeMuscleArea: 77,
|
||||
backfatThickness: 80,
|
||||
bodyConformation: 79
|
||||
},
|
||||
strengths: ['등지방두께'],
|
||||
weaknesses: ['근내지방도', '등심단면적', '도체중', '체형'],
|
||||
recentPerformance: {
|
||||
carcassWeight: 415,
|
||||
marblingScore: 3,
|
||||
eyeMuscleArea: 78
|
||||
},
|
||||
recommendations: '전반적인 개선이 필요한 개체입니다. 종합적인 개량 전략을 수립하세요.'
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6 lg:p-8">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.back()}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">우수 개체 순위</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
유전체 점수 기준 전체 개체 순위
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">전체 개체</div>
|
||||
<div className="text-3xl font-bold">{topCows.length}두</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">A등급</div>
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{topCows.filter(cow => cow.grade === 'A').length}두
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">평균 점수</div>
|
||||
<div className="text-3xl font-bold text-purple-600">
|
||||
{(topCows.reduce((sum, cow) => sum + cow.score, 0) / topCows.length).toFixed(1)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-sm text-muted-foreground mb-1">최고 점수</div>
|
||||
<div className="text-3xl font-bold text-emerald-600">
|
||||
{Math.max(...topCows.map(cow => cow.score)).toFixed(1)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 개체 상세 리스트 */}
|
||||
<div className="space-y-4">
|
||||
{topCows.map((cow, idx) => (
|
||||
<Card key={idx} className="overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50/30 border-b">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-14 h-14 rounded-full flex items-center justify-center text-xl font-bold shadow-md ${
|
||||
cow.rank === 1 ? 'bg-gradient-to-br from-yellow-400 to-yellow-500 text-white' :
|
||||
cow.rank === 2 ? 'bg-gradient-to-br from-slate-300 to-slate-400 text-white' :
|
||||
cow.rank === 3 ? 'bg-gradient-to-br from-amber-600 to-amber-700 text-white' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{cow.rank}
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
{cow.name}
|
||||
<Badge className={
|
||||
cow.grade === 'A' ? 'bg-blue-100 text-blue-700 border-blue-200' :
|
||||
'bg-slate-100 text-slate-700 border-slate-200'
|
||||
}>
|
||||
{cow.grade}등급
|
||||
</Badge>
|
||||
{cow.rank <= 3 && (
|
||||
<Trophy className="h-5 w-5 text-yellow-500" />
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
개체번호: {cow.cowNo} · 나이: {cow.age} · 산차: {cow.lactationCount}산
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-blue-600">{cow.score}</div>
|
||||
<div className="text-sm text-muted-foreground">점</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 좌측: 형질 점수 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
||||
<Star className="h-4 w-4 text-blue-600" />
|
||||
형질별 점수
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(cow.traits).map(([trait, score]) => {
|
||||
const traitNames: Record<string, string> = {
|
||||
carcassWeight: '도체중',
|
||||
marbling: '근내지방도',
|
||||
eyeMuscleArea: '등심단면적',
|
||||
backfatThickness: '등지방두께',
|
||||
bodyConformation: '체형'
|
||||
}
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 90) return 'bg-blue-600'
|
||||
if (score >= 80) return 'bg-blue-500'
|
||||
if (score >= 70) return 'bg-blue-400'
|
||||
return 'bg-slate-400'
|
||||
}
|
||||
return (
|
||||
<div key={trait} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-700">{traitNames[trait]}</span>
|
||||
<span className="font-bold text-slate-900">{score}점</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-2.5">
|
||||
<div
|
||||
className={`${getScoreColor(score)} h-2.5 rounded-full transition-all`}
|
||||
style={{ width: `${score}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 성능 데이터 및 분석 */}
|
||||
<div className="space-y-4">
|
||||
{/* 최근 성적 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-emerald-600" />
|
||||
예상 도체 성적
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-emerald-50 p-3 rounded-lg border border-emerald-100">
|
||||
<div className="text-xs text-emerald-700 mb-1">도체중</div>
|
||||
<div className="text-lg font-bold text-emerald-600">
|
||||
{cow.recentPerformance.carcassWeight}
|
||||
</div>
|
||||
<div className="text-xs text-emerald-600">kg</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-3 rounded-lg border border-blue-100">
|
||||
<div className="text-xs text-blue-700 mb-1">근내지방</div>
|
||||
<div className="text-lg font-bold text-blue-600">
|
||||
{cow.recentPerformance.marblingScore}
|
||||
</div>
|
||||
<div className="text-xs text-blue-600">번</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-3 rounded-lg border border-purple-100">
|
||||
<div className="text-xs text-purple-700 mb-1">등심단면적</div>
|
||||
<div className="text-lg font-bold text-purple-600">
|
||||
{cow.recentPerformance.eyeMuscleArea}
|
||||
</div>
|
||||
<div className="text-xs text-purple-600">cm²</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 강점/약점 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-900 mb-2">강점 / 약점</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs text-emerald-700 font-semibold mt-0.5">강점:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cow.strengths.map((strength, i) => (
|
||||
<Badge key={i} className="bg-emerald-100 text-emerald-700 border-emerald-200 text-xs">
|
||||
{strength}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs text-amber-700 font-semibold mt-0.5">약점:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cow.weaknesses.map((weakness, i) => (
|
||||
<Badge key={i} className="bg-amber-100 text-amber-700 border-amber-200 text-xs">
|
||||
{weakness}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 권장사항 */}
|
||||
<div className="bg-blue-50/50 p-3 rounded-lg border border-blue-100">
|
||||
<h4 className="text-xs font-semibold text-blue-900 mb-1">💡 권장사항</h4>
|
||||
<p className="text-xs text-blue-700 leading-relaxed">
|
||||
{cow.recommendations}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 액션 버튼 */}
|
||||
<div className="mt-6 pt-4 border-t flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => router.push(`/cow/${cow.cowNo}`)}
|
||||
className="flex-1"
|
||||
>
|
||||
개체 상세정보
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/recommended-kpn?cowNo=${cow.cowNo}`)}
|
||||
className="flex-1"
|
||||
>
|
||||
추천 KPN 보기
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
485
frontend/src/app/demo/chart-options/page.tsx
Normal file
485
frontend/src/app/demo/chart-options/page.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
Area,
|
||||
ComposedChart,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
ReferenceLine,
|
||||
Customized,
|
||||
} from 'recharts'
|
||||
|
||||
// 샘플 데이터
|
||||
const SAMPLE_DATA = {
|
||||
cow: { name: '7805', score: 0.85 },
|
||||
farm: { name: '농가', score: 0.53 },
|
||||
region: { name: '보은군', score: 0.21 },
|
||||
}
|
||||
|
||||
// 정규분포 히스토그램 데이터
|
||||
const histogramData = [
|
||||
{ midPoint: -2.5, percent: 2.3 },
|
||||
{ midPoint: -2.0, percent: 4.4 },
|
||||
{ midPoint: -1.5, percent: 9.2 },
|
||||
{ midPoint: -1.0, percent: 15.0 },
|
||||
{ midPoint: -0.5, percent: 19.1 },
|
||||
{ midPoint: 0.0, percent: 19.1 },
|
||||
{ midPoint: 0.5, percent: 15.0 },
|
||||
{ midPoint: 1.0, percent: 9.2 },
|
||||
{ midPoint: 1.5, percent: 4.4 },
|
||||
{ midPoint: 2.0, percent: 2.3 },
|
||||
]
|
||||
|
||||
export default function ChartOptionsDemo() {
|
||||
const [selectedOption, setSelectedOption] = useState<string>('A')
|
||||
|
||||
const cowScore = SAMPLE_DATA.cow.score
|
||||
const farmScore = SAMPLE_DATA.farm.score
|
||||
const regionScore = SAMPLE_DATA.region.score
|
||||
|
||||
const farmDiff = cowScore - farmScore
|
||||
const regionDiff = cowScore - regionScore
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 p-4 sm:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<h1 className="text-2xl font-bold text-foreground">차트 대비 표시 옵션 데모</h1>
|
||||
<p className="text-muted-foreground">
|
||||
개체: +{cowScore.toFixed(2)} | 농가: +{farmScore.toFixed(2)} | 보은군: +{regionScore.toFixed(2)}
|
||||
</p>
|
||||
|
||||
{/* 옵션 선택 탭 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['A', 'B', 'C', 'D', 'E'].map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => setSelectedOption(opt)}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
selectedOption === opt
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-white text-foreground hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
옵션 {opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 옵션 A: 차트 내에 대비값 항상 표시 */}
|
||||
{selectedOption === 'A' && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h2 className="text-lg font-bold mb-2">옵션 A: 차트 내에 대비값 항상 표시</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">개체 선 옆에 농가/보은군 대비값을 직접 표시</p>
|
||||
|
||||
<div className="h-[400px] bg-slate-50 rounded-xl p-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={histogramData} margin={{ top: 80, right: 30, left: 10, bottom: 30 }}>
|
||||
<defs>
|
||||
<linearGradient id="areaGradientA" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#93c5fd" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="midPoint" type="number" domain={[-2.5, 2.5]} tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} width={30} />
|
||||
<Area type="natural" dataKey="percent" stroke="#3b82f6" fill="url(#areaGradientA)" />
|
||||
<ReferenceLine x={regionScore} stroke="#2563eb" strokeWidth={2} strokeDasharray="4 2" />
|
||||
<ReferenceLine x={farmScore} stroke="#f59e0b" strokeWidth={2} strokeDasharray="4 2" />
|
||||
<ReferenceLine x={cowScore} stroke="#1482B0" strokeWidth={3} />
|
||||
|
||||
<Customized
|
||||
component={(props: any) => {
|
||||
const { xAxisMap, yAxisMap } = props
|
||||
if (!xAxisMap || !yAxisMap) return null
|
||||
const xAxis = Object.values(xAxisMap)[0] as any
|
||||
const yAxis = Object.values(yAxisMap)[0] as any
|
||||
if (!xAxis || !yAxis) return null
|
||||
|
||||
const chartX = xAxis.x
|
||||
const chartWidth = xAxis.width
|
||||
const chartTop = yAxis.y
|
||||
const [domainMin, domainMax] = xAxis.domain || [-2.5, 2.5]
|
||||
const domainRange = domainMax - domainMin
|
||||
|
||||
const sigmaToX = (sigma: number) => chartX + ((sigma - domainMin) / domainRange) * chartWidth
|
||||
const cowX = sigmaToX(cowScore)
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* 개체 라벨 + 대비값 */}
|
||||
<rect x={cowX + 10} y={chartTop + 20} width={120} height={50} rx={6} fill="#1482B0" />
|
||||
<text x={cowX + 70} y={chartTop + 38} textAnchor="middle" fill="white" fontSize={12} fontWeight={600}>
|
||||
개체 +{cowScore.toFixed(2)}
|
||||
</text>
|
||||
<text x={cowX + 70} y={chartTop + 55} textAnchor="middle" fill="white" fontSize={10}>
|
||||
농가+{farmDiff.toFixed(2)} | 보은군+{regionDiff.toFixed(2)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 옵션 B: 선 사이 영역 색으로 채우기 */}
|
||||
{selectedOption === 'B' && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h2 className="text-lg font-bold mb-2">옵션 B: 선 사이 영역 색으로 채우기</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">개체~농가, 개체~보은군 사이를 색으로 강조</p>
|
||||
|
||||
<div className="h-[400px] bg-slate-50 rounded-xl p-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={histogramData} margin={{ top: 80, right: 30, left: 10, bottom: 30 }}>
|
||||
<defs>
|
||||
<linearGradient id="areaGradientB" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#93c5fd" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="midPoint" type="number" domain={[-2.5, 2.5]} tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} width={30} />
|
||||
<Area type="natural" dataKey="percent" stroke="#3b82f6" fill="url(#areaGradientB)" />
|
||||
<ReferenceLine x={regionScore} stroke="#2563eb" strokeWidth={2} strokeDasharray="4 2" />
|
||||
<ReferenceLine x={farmScore} stroke="#f59e0b" strokeWidth={2} strokeDasharray="4 2" />
|
||||
<ReferenceLine x={cowScore} stroke="#1482B0" strokeWidth={3} />
|
||||
|
||||
<Customized
|
||||
component={(props: any) => {
|
||||
const { xAxisMap, yAxisMap } = props
|
||||
if (!xAxisMap || !yAxisMap) return null
|
||||
const xAxis = Object.values(xAxisMap)[0] as any
|
||||
const yAxis = Object.values(yAxisMap)[0] as any
|
||||
if (!xAxis || !yAxis) return null
|
||||
|
||||
const chartX = xAxis.x
|
||||
const chartWidth = xAxis.width
|
||||
const chartTop = yAxis.y
|
||||
const chartHeight = yAxis.height
|
||||
const [domainMin, domainMax] = xAxis.domain || [-2.5, 2.5]
|
||||
const domainRange = domainMax - domainMin
|
||||
|
||||
const sigmaToX = (sigma: number) => chartX + ((sigma - domainMin) / domainRange) * chartWidth
|
||||
const cowX = sigmaToX(cowScore)
|
||||
const farmX = sigmaToX(farmScore)
|
||||
const regionX = sigmaToX(regionScore)
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* 개체~농가 영역 (주황색) */}
|
||||
<rect
|
||||
x={farmX}
|
||||
y={chartTop}
|
||||
width={cowX - farmX}
|
||||
height={chartHeight}
|
||||
fill="rgba(245, 158, 11, 0.25)"
|
||||
/>
|
||||
{/* 농가~보은군 영역 (파란색) */}
|
||||
<rect
|
||||
x={regionX}
|
||||
y={chartTop}
|
||||
width={farmX - regionX}
|
||||
height={chartHeight}
|
||||
fill="rgba(37, 99, 235, 0.15)"
|
||||
/>
|
||||
|
||||
{/* 대비값 라벨 */}
|
||||
<rect x={(cowX + farmX) / 2 - 35} y={chartTop + 30} width={70} height={24} rx={4} fill="#f59e0b" />
|
||||
<text x={(cowX + farmX) / 2} y={chartTop + 46} textAnchor="middle" fill="white" fontSize={11} fontWeight={600}>
|
||||
+{farmDiff.toFixed(2)}
|
||||
</text>
|
||||
|
||||
<rect x={(farmX + regionX) / 2 - 35} y={chartTop + 60} width={70} height={24} rx={4} fill="#2563eb" />
|
||||
<text x={(farmX + regionX) / 2} y={chartTop + 76} textAnchor="middle" fill="white" fontSize={11} fontWeight={600}>
|
||||
+{(farmScore - regionScore).toFixed(2)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 옵션 C: 개체 배지에 대비값 추가 */}
|
||||
{selectedOption === 'C' && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h2 className="text-lg font-bold mb-2">옵션 C: 개체 배지에 대비값 추가</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">개체 배지를 확장해서 대비값 포함</p>
|
||||
|
||||
<div className="h-[400px] bg-slate-50 rounded-xl p-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={histogramData} margin={{ top: 100, right: 30, left: 10, bottom: 30 }}>
|
||||
<defs>
|
||||
<linearGradient id="areaGradientC" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#93c5fd" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="midPoint" type="number" domain={[-2.5, 2.5]} tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} width={30} />
|
||||
<Area type="natural" dataKey="percent" stroke="#3b82f6" fill="url(#areaGradientC)" />
|
||||
<ReferenceLine x={regionScore} stroke="#2563eb" strokeWidth={2} strokeDasharray="4 2" />
|
||||
<ReferenceLine x={farmScore} stroke="#f59e0b" strokeWidth={2} strokeDasharray="4 2" />
|
||||
<ReferenceLine x={cowScore} stroke="#1482B0" strokeWidth={3} />
|
||||
|
||||
<Customized
|
||||
component={(props: any) => {
|
||||
const { xAxisMap, yAxisMap } = props
|
||||
if (!xAxisMap || !yAxisMap) return null
|
||||
const xAxis = Object.values(xAxisMap)[0] as any
|
||||
const yAxis = Object.values(yAxisMap)[0] as any
|
||||
if (!xAxis || !yAxis) return null
|
||||
|
||||
const chartX = xAxis.x
|
||||
const chartWidth = xAxis.width
|
||||
const chartTop = yAxis.y
|
||||
const [domainMin, domainMax] = xAxis.domain || [-2.5, 2.5]
|
||||
const domainRange = domainMax - domainMin
|
||||
|
||||
const sigmaToX = (sigma: number) => chartX + ((sigma - domainMin) / domainRange) * chartWidth
|
||||
const cowX = sigmaToX(cowScore)
|
||||
const farmX = sigmaToX(farmScore)
|
||||
const regionX = sigmaToX(regionScore)
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* 보은군 배지 */}
|
||||
<rect x={regionX - 50} y={chartTop - 85} width={100} height={26} rx={6} fill="#dbeafe" stroke="#93c5fd" strokeWidth={2} />
|
||||
<text x={regionX} y={chartTop - 68} textAnchor="middle" fill="#2563eb" fontSize={12} fontWeight={600}>
|
||||
보은군 +{regionScore.toFixed(2)}
|
||||
</text>
|
||||
|
||||
{/* 농가 배지 */}
|
||||
<rect x={farmX - 50} y={chartTop - 55} width={100} height={26} rx={6} fill="#fef3c7" stroke="#fcd34d" strokeWidth={2} />
|
||||
<text x={farmX} y={chartTop - 38} textAnchor="middle" fill="#d97706" fontSize={12} fontWeight={600}>
|
||||
농가 +{farmScore.toFixed(2)}
|
||||
</text>
|
||||
|
||||
{/* 개체 배지 (확장) */}
|
||||
<rect x={cowX - 80} y={chartTop - 25} width={160} height={40} rx={6} fill="#1482B0" />
|
||||
<text x={cowX} y={chartTop - 8} textAnchor="middle" fill="white" fontSize={13} fontWeight={700}>
|
||||
개체 +{cowScore.toFixed(2)}
|
||||
</text>
|
||||
<text x={cowX} y={chartTop + 10} textAnchor="middle" fill="rgba(255,255,255,0.9)" fontSize={10}>
|
||||
농가 대비 +{farmDiff.toFixed(2)} | 보은군 대비 +{regionDiff.toFixed(2)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 옵션 D: 차트 모서리에 오버레이 박스 */}
|
||||
{selectedOption === 'D' && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h2 className="text-lg font-bold mb-2">옵션 D: 차트 모서리에 오버레이 박스</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">차트 우측 상단에 대비값 요약 박스</p>
|
||||
|
||||
<div className="h-[400px] bg-slate-50 rounded-xl p-4 relative">
|
||||
{/* 오버레이 박스 */}
|
||||
<div className="absolute top-6 right-6 bg-white rounded-lg shadow-lg border border-slate-200 p-3 z-10">
|
||||
<div className="text-xs text-muted-foreground mb-2">개체 대비</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded bg-amber-500"></span>
|
||||
<span className="text-sm">농가</span>
|
||||
</span>
|
||||
<span className="text-sm font-bold text-green-600">+{farmDiff.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded bg-blue-500"></span>
|
||||
<span className="text-sm">보은군</span>
|
||||
</span>
|
||||
<span className="text-sm font-bold text-green-600">+{regionDiff.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={histogramData} margin={{ top: 80, right: 30, left: 10, bottom: 30 }}>
|
||||
<defs>
|
||||
<linearGradient id="areaGradientD" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#93c5fd" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="midPoint" type="number" domain={[-2.5, 2.5]} tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} width={30} />
|
||||
<Area type="natural" dataKey="percent" stroke="#3b82f6" fill="url(#areaGradientD)" />
|
||||
<ReferenceLine x={regionScore} stroke="#2563eb" strokeWidth={2} strokeDasharray="4 2" />
|
||||
<ReferenceLine x={farmScore} stroke="#f59e0b" strokeWidth={2} strokeDasharray="4 2" />
|
||||
<ReferenceLine x={cowScore} stroke="#1482B0" strokeWidth={3} />
|
||||
|
||||
<Customized
|
||||
component={(props: any) => {
|
||||
const { xAxisMap, yAxisMap } = props
|
||||
if (!xAxisMap || !yAxisMap) return null
|
||||
const xAxis = Object.values(xAxisMap)[0] as any
|
||||
const yAxis = Object.values(yAxisMap)[0] as any
|
||||
if (!xAxis || !yAxis) return null
|
||||
|
||||
const chartX = xAxis.x
|
||||
const chartWidth = xAxis.width
|
||||
const chartTop = yAxis.y
|
||||
const [domainMin, domainMax] = xAxis.domain || [-2.5, 2.5]
|
||||
const domainRange = domainMax - domainMin
|
||||
|
||||
const sigmaToX = (sigma: number) => chartX + ((sigma - domainMin) / domainRange) * chartWidth
|
||||
const cowX = sigmaToX(cowScore)
|
||||
const farmX = sigmaToX(farmScore)
|
||||
const regionX = sigmaToX(regionScore)
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* 심플 배지들 */}
|
||||
<rect x={regionX - 40} y={chartTop - 60} width={80} height={22} rx={4} fill="#dbeafe" stroke="#93c5fd" />
|
||||
<text x={regionX} y={chartTop - 45} textAnchor="middle" fill="#2563eb" fontSize={11} fontWeight={600}>보은군</text>
|
||||
|
||||
<rect x={farmX - 30} y={chartTop - 35} width={60} height={22} rx={4} fill="#fef3c7" stroke="#fcd34d" />
|
||||
<text x={farmX} y={chartTop - 20} textAnchor="middle" fill="#d97706" fontSize={11} fontWeight={600}>농가</text>
|
||||
|
||||
<rect x={cowX - 30} y={chartTop - 10} width={60} height={22} rx={4} fill="#1482B0" />
|
||||
<text x={cowX} y={chartTop + 5} textAnchor="middle" fill="white" fontSize={11} fontWeight={600}>개체</text>
|
||||
</g>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 옵션 E: 화살표로 차이 표시 */}
|
||||
{selectedOption === 'E' && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h2 className="text-lg font-bold mb-2">옵션 E: 화살표로 차이 표시</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">개체에서 농가/보은군으로 화살표 + 차이값</p>
|
||||
|
||||
<div className="h-[400px] bg-slate-50 rounded-xl p-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={histogramData} margin={{ top: 80, right: 30, left: 10, bottom: 30 }}>
|
||||
<defs>
|
||||
<linearGradient id="areaGradientE" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#93c5fd" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
<marker id="arrowFarm" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L0,6 L9,3 z" fill="#f59e0b" />
|
||||
</marker>
|
||||
<marker id="arrowRegion" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L0,6 L9,3 z" fill="#2563eb" />
|
||||
</marker>
|
||||
</defs>
|
||||
<XAxis dataKey="midPoint" type="number" domain={[-2.5, 2.5]} tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} width={30} />
|
||||
<Area type="natural" dataKey="percent" stroke="#3b82f6" fill="url(#areaGradientE)" />
|
||||
<ReferenceLine x={regionScore} stroke="#2563eb" strokeWidth={2} strokeDasharray="4 2" />
|
||||
<ReferenceLine x={farmScore} stroke="#f59e0b" strokeWidth={2} strokeDasharray="4 2" />
|
||||
<ReferenceLine x={cowScore} stroke="#1482B0" strokeWidth={3} />
|
||||
|
||||
<Customized
|
||||
component={(props: any) => {
|
||||
const { xAxisMap, yAxisMap } = props
|
||||
if (!xAxisMap || !yAxisMap) return null
|
||||
const xAxis = Object.values(xAxisMap)[0] as any
|
||||
const yAxis = Object.values(yAxisMap)[0] as any
|
||||
if (!xAxis || !yAxis) return null
|
||||
|
||||
const chartX = xAxis.x
|
||||
const chartWidth = xAxis.width
|
||||
const chartTop = yAxis.y
|
||||
const chartHeight = yAxis.height
|
||||
const [domainMin, domainMax] = xAxis.domain || [-2.5, 2.5]
|
||||
const domainRange = domainMax - domainMin
|
||||
|
||||
const sigmaToX = (sigma: number) => chartX + ((sigma - domainMin) / domainRange) * chartWidth
|
||||
const cowX = sigmaToX(cowScore)
|
||||
const farmX = sigmaToX(farmScore)
|
||||
const regionX = sigmaToX(regionScore)
|
||||
|
||||
const arrowY1 = chartTop + chartHeight * 0.3
|
||||
const arrowY2 = chartTop + chartHeight * 0.5
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* 개체 → 농가 화살표 */}
|
||||
<line
|
||||
x1={cowX} y1={arrowY1}
|
||||
x2={farmX + 10} y2={arrowY1}
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={3}
|
||||
markerEnd="url(#arrowFarm)"
|
||||
/>
|
||||
<rect x={(cowX + farmX) / 2 - 30} y={arrowY1 - 22} width={60} height={20} rx={4} fill="#f59e0b" />
|
||||
<text x={(cowX + farmX) / 2} y={arrowY1 - 8} textAnchor="middle" fill="white" fontSize={11} fontWeight={700}>
|
||||
+{farmDiff.toFixed(2)}
|
||||
</text>
|
||||
|
||||
{/* 개체 → 보은군 화살표 */}
|
||||
<line
|
||||
x1={cowX} y1={arrowY2}
|
||||
x2={regionX + 10} y2={arrowY2}
|
||||
stroke="#2563eb"
|
||||
strokeWidth={3}
|
||||
markerEnd="url(#arrowRegion)"
|
||||
/>
|
||||
<rect x={(cowX + regionX) / 2 - 30} y={arrowY2 - 22} width={60} height={20} rx={4} fill="#2563eb" />
|
||||
<text x={(cowX + regionX) / 2} y={arrowY2 - 8} textAnchor="middle" fill="white" fontSize={11} fontWeight={700}>
|
||||
+{regionDiff.toFixed(2)}
|
||||
</text>
|
||||
|
||||
{/* 배지 */}
|
||||
<rect x={cowX - 30} y={chartTop - 25} width={60} height={22} rx={4} fill="#1482B0" />
|
||||
<text x={cowX} y={chartTop - 10} textAnchor="middle" fill="white" fontSize={11} fontWeight={600}>개체</text>
|
||||
</g>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 옵션 설명 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h2 className="text-lg font-bold mb-3">옵션 비교</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div><strong>A:</strong> 개체 배지 옆에 대비값 직접 표시 - 간단하지만 정보 밀집</div>
|
||||
<div><strong>B:</strong> 영역 색칠로 거리감 강조 - 시각적으로 차이가 명확</div>
|
||||
<div><strong>C:</strong> 개체 배지 확장 - 배지에 모든 정보 포함</div>
|
||||
<div><strong>D:</strong> 오버레이 박스 - 차트 방해 없이 정보 제공</div>
|
||||
<div><strong>E:</strong> 화살표 - 방향성과 차이 동시 표현</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1364
frontend/src/app/demo/dual-axis/page.tsx
Normal file
1364
frontend/src/app/demo/dual-axis/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
48
frontend/src/app/findid/page.tsx
Normal file
48
frontend/src/app/findid/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { FindIdForm } from "@/components/auth/findid-form"
|
||||
import Image from "next/image"
|
||||
|
||||
/**
|
||||
* 아이디 찾기 페이지
|
||||
*
|
||||
* @export
|
||||
* @returns {*}
|
||||
*/
|
||||
export default function FindIdPage() {
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
{/* 데스크톱: 왼쪽 로고 이미지 영역 */}
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image
|
||||
src="/logo-graphic.svg"
|
||||
alt="한우 유전능력 컨설팅 로고"
|
||||
fill
|
||||
className="object-contain p-16 -translate-y-12 translate-x-24"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 폼 영역 */}
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
{/* 모바일: 상단 로고 - 폼 바로 위에 배치 */}
|
||||
<div className="lg:hidden flex justify-center mb-6 mt-4">
|
||||
<Image
|
||||
src="/logo-graphic.svg"
|
||||
alt="한우 유전능력 컨설팅 로고"
|
||||
width={200}
|
||||
height={200}
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<FindIdForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
frontend/src/app/findpw/page.tsx
Normal file
42
frontend/src/app/findpw/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { FindPwForm } from "@/components/auth/findpw-form"
|
||||
import Image from "next/image"
|
||||
|
||||
export default function FindPwPage() {
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
{/* 데스크톱: 왼쪽 로고 이미지 영역 */}
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image
|
||||
src="/logo-graphic.svg"
|
||||
alt="한우 유전능력 컨설팅 로고"
|
||||
fill
|
||||
className="object-contain p-16 -translate-y-12 translate-x-24"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 폼 영역 */}
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
{/* 모바일: 상단 로고 - 폼 바로 위에 배치 */}
|
||||
<div className="lg:hidden flex justify-center mb-6 mt-4">
|
||||
<Image
|
||||
src="/logo-graphic.svg"
|
||||
alt="한우 유전능력 컨설팅 로고"
|
||||
width={200}
|
||||
height={200}
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<FindPwForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
429
frontend/src/app/globals.css
Normal file
429
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,429 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans: Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
|
||||
/* Core Colors - Modern Hanwoo Design System */
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #0f172a;
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #0f172a;
|
||||
--color-popover: #ffffff;
|
||||
--color-popover-foreground: #0f172a;
|
||||
--color-primary: #1F3A8F;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #f1f5f9;
|
||||
--color-secondary-foreground: #0f172a;
|
||||
--color-muted: #f8fafc;
|
||||
--color-muted-foreground: #64748b;
|
||||
--color-accent: #f59e0b;
|
||||
--color-accent-foreground: #ffffff;
|
||||
--color-destructive: #ef4444;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--color-border: #e2e8f0;
|
||||
--color-input: #e2e8f0;
|
||||
--color-ring: #1F3A8F;
|
||||
|
||||
/* Chart Colors */
|
||||
--color-chart-1: #2563eb;
|
||||
--color-chart-2: #f59e0b;
|
||||
--color-chart-3: #10b981;
|
||||
--color-chart-4: #ef4444;
|
||||
--color-chart-5: #8b5cf6;
|
||||
|
||||
/* Sidebar Colors - Modern White Theme */
|
||||
--color-sidebar: #ffffff;
|
||||
--color-sidebar-foreground: #0f172a;
|
||||
--color-sidebar-primary: #1F3A8F;
|
||||
--color-sidebar-primary-foreground: #ffffff;
|
||||
--color-sidebar-accent: #f1f5f9;
|
||||
--color-sidebar-accent-foreground: #0f172a;
|
||||
--color-sidebar-border: #e2e8f0;
|
||||
--color-sidebar-ring: #1F3A8F;
|
||||
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
}
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* 전체적인 글씨 크기 확대 - 반응형 클래스와 충돌 방지를 위해 !important 제거 */
|
||||
.text-sm {
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.8125rem; /* 13px */
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.text-base {
|
||||
font-size: 1rem; /* 16px */
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: 1.125rem; /* 18px */
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.text-xl {
|
||||
font-size: 1.25rem; /* 20px */
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.text-2xl {
|
||||
font-size: 1.5rem; /* 24px */
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.text-3xl {
|
||||
font-size: 1.875rem; /* 30px */
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* Custom Animations */
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slide-down 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Grade Badge Classes (등급별 배지) - 도메인 특화 스타일 유지 */
|
||||
.badge-grade-a {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: rgb(34, 197, 94);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
.badge-grade-b {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: rgb(59, 130, 246);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
.badge-grade-c {
|
||||
background-color: rgba(251, 191, 36, 0.1);
|
||||
color: rgb(251, 191, 36);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
.badge-grade-d {
|
||||
background-color: rgba(249, 115, 22, 0.1);
|
||||
color: rgb(249, 115, 22);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
.badge-grade-e {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: rgb(239, 68, 68);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
/* Gene Marker Classes (유전자 마커) */
|
||||
.badge-gene-positive {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: rgb(34, 197, 94);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
.badge-gene-negative {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: rgb(239, 68, 68);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
.badge-gene-neutral {
|
||||
background-color: rgba(107, 114, 128, 0.1);
|
||||
color: rgb(107, 114, 128);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
}
|
||||
|
||||
/* Text Color Classes (등급별 텍스트 색상) */
|
||||
.text-grade-a {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
.text-grade-b {
|
||||
color: rgb(59, 130, 246);
|
||||
}
|
||||
|
||||
.text-grade-c {
|
||||
color: rgb(251, 191, 36);
|
||||
}
|
||||
|
||||
.text-grade-d {
|
||||
color: rgb(249, 115, 22);
|
||||
}
|
||||
|
||||
.text-grade-e {
|
||||
color: rgb(239, 68, 68);
|
||||
}
|
||||
|
||||
.text-gene-positive {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
.text-gene-negative {
|
||||
color: rgb(239, 68, 68);
|
||||
}
|
||||
|
||||
/* Background Color Classes */
|
||||
.bg-grade-a {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.bg-grade-b {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.bg-grade-c {
|
||||
background-color: rgba(251, 191, 36, 0.1);
|
||||
}
|
||||
|
||||
.bg-grade-d {
|
||||
background-color: rgba(249, 115, 22, 0.1);
|
||||
}
|
||||
|
||||
.bg-grade-e {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* Progress Indicator Colors (등급별 프로그레스 바 색상) */
|
||||
[data-slot="indicator"].bg-grade-a {
|
||||
background-color: rgb(34, 197, 94) !important;
|
||||
}
|
||||
|
||||
[data-slot="indicator"].bg-grade-b {
|
||||
background-color: rgb(59, 130, 246) !important;
|
||||
}
|
||||
|
||||
[data-slot="indicator"].bg-grade-c {
|
||||
background-color: rgb(251, 191, 36) !important;
|
||||
}
|
||||
|
||||
[data-slot="indicator"].bg-grade-d {
|
||||
background-color: rgb(249, 115, 22) !important;
|
||||
}
|
||||
|
||||
[data-slot="indicator"].bg-grade-e {
|
||||
background-color: rgb(239, 68, 68) !important;
|
||||
}
|
||||
|
||||
/* Gene Progress Indicator Colors (유전자 마커 프로그레스 바) */
|
||||
[data-slot="indicator"].bg-gene-positive {
|
||||
background-color: rgb(34, 197, 94) !important;
|
||||
}
|
||||
|
||||
[data-slot="indicator"].bg-gene-negative {
|
||||
background-color: rgb(239, 68, 68) !important;
|
||||
}
|
||||
|
||||
[data-slot="indicator"].bg-gene-neutral {
|
||||
background-color: rgb(107, 114, 128) !important;
|
||||
}
|
||||
|
||||
/* Scrollbar Hide Utility */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Line Clamp Utilities */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
개체목록 테이블 (Cow List Table)
|
||||
============================================ */
|
||||
:root {
|
||||
/* 컬럼 너비 */
|
||||
--cow-col-w-rank: 50px;
|
||||
--cow-col-w-cowNo: 220px;
|
||||
--cow-col-w-birth: 90px;
|
||||
--cow-col-w-age: 70px;
|
||||
--cow-col-w-sex: 60px;
|
||||
--cow-col-w-dam: 100px;
|
||||
--cow-col-w-sire: 90px;
|
||||
--cow-col-w-inbreeding: 60px;
|
||||
--cow-col-w-genomeScore: 100px;
|
||||
--cow-col-w-dynamic: 140px;
|
||||
}
|
||||
|
||||
/* 테이블 헤더 셀 */
|
||||
.cow-table-header {
|
||||
@apply text-center py-3 px-3 font-semibold;
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
}
|
||||
|
||||
/* 테이블 바디 셀 */
|
||||
.cow-table-cell {
|
||||
@apply text-center py-3 px-3;
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
}
|
||||
|
||||
/* 분석불가 행 - 각 td에 오버레이 */
|
||||
.cow-row-unavailable td {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.cow-row-unavailable td::after {
|
||||
content: '';
|
||||
@apply absolute inset-0 bg-black/40 pointer-events-none;
|
||||
}
|
||||
|
||||
.cow-row-unavailable:hover {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
30
frontend/src/app/layout.tsx
Normal file
30
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import "pretendard/dist/web/static/pretendard-dynamic-subset.css";
|
||||
import "./globals.css";
|
||||
import { GlobalFilterProvider } from "@/contexts/GlobalFilterContext";
|
||||
import { AnalysisYearProvider } from "@/contexts/AnalysisYearContext";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "한우 유전능력 컨설팅 서비스",
|
||||
description: "한우 개체 유전능력 분석 및 KPN 추천 서비스",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body className="antialiased" style={{ fontFamily: 'Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif' }}>
|
||||
<GlobalFilterProvider>
|
||||
<AnalysisYearProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</AnalysisYearProvider>
|
||||
</GlobalFilterProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
91
frontend/src/app/login/page.tsx
Normal file
91
frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
// 로그인 페이지 컴포넌트
|
||||
export default function LoginPage() {
|
||||
const [userId, setUserId] = useState('')
|
||||
const [userPassword, setUserPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const { login } = useAuthStore();
|
||||
|
||||
// 로그인 처리 함수
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await login({
|
||||
userId: userId,
|
||||
userPassword: userPassword
|
||||
})
|
||||
|
||||
// 로그인 성공 후 사용자 정보 가져오기
|
||||
const userInfo = useAuthStore.getState().user
|
||||
const isAdmin = userInfo?.userRole === 'ADMIN'
|
||||
|
||||
// 관리자는 /admin, 일반 사용자는 /dashboard로 이동
|
||||
router.push(isAdmin ? '/admin' : '/dashboard')
|
||||
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('알 수 없는 오류가 발생했습니다')
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 렌더링
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
{/* 데스크톱: 왼쪽 로고 이미지 영역 */}
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image
|
||||
src="/logo-graphic.svg"
|
||||
alt="한우 유전능력 컨설팅 로고"
|
||||
fill
|
||||
className="object-contain p-16 -translate-y-12 translate-x-24"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 폼 영역 */}
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
{/* 모바일: 상단 로고 - 폼 바로 위에 배치 */}
|
||||
<div className="lg:hidden flex justify-center mb-6 mt-4">
|
||||
<Image
|
||||
src="/logo-graphic.svg"
|
||||
alt="한우 유전능력 컨설팅 로고"
|
||||
width={200}
|
||||
height={200}
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-start lg:pl-24">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<LoginForm
|
||||
onSubmit={handleLogin}
|
||||
userId={userId}
|
||||
setUserId={setUserId}
|
||||
userPassword={userPassword}
|
||||
setUserPassword={setUserPassword}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
frontend/src/app/page.tsx
Normal file
46
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/store/auth-store";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, accessToken, loadProfile, clearAuth } = useAuthStore();
|
||||
const [isValidating, setIsValidating] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const validateAuth = async () => {
|
||||
// 토큰이 있으면 유효성 검증
|
||||
if (isAuthenticated && accessToken) {
|
||||
try {
|
||||
// 프로필 로드로 토큰 유효성 확인
|
||||
// await loadProfile(); // 👈 주석처리: 백엔드 /users/profile 미구현으로 인한 401 에러 방지
|
||||
// 성공하면 대시보드로
|
||||
router.push("/dashboard");
|
||||
} catch (error) {
|
||||
// 실패하면 로그아웃 처리
|
||||
console.error('토큰 유효성 검증 실패:', error);
|
||||
clearAuth();
|
||||
router.push("/login");
|
||||
}
|
||||
} else {
|
||||
// 토큰 없으면 로그인 페이지로
|
||||
router.push("/login");
|
||||
}
|
||||
setIsValidating(false);
|
||||
};
|
||||
|
||||
validateAuth();
|
||||
}, []); // 빈 배열로 최초 1회만 실행
|
||||
|
||||
// 리다이렉트 중 로딩 표시
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
frontend/src/app/sha/page.tsx
Normal file
7
frontend/src/app/sha/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Button>Click me</Button>
|
||||
)
|
||||
}
|
||||
260
frontend/src/app/signup/page.tsx
Normal file
260
frontend/src/app/signup/page.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
'use client';
|
||||
|
||||
import Image from "next/image"
|
||||
import { SignupForm } from "@/components/auth/signup-form"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useAuthStore } from "@/store/auth-store"
|
||||
import { authApi } from "@/lib/api/auth.api"
|
||||
import { SignupFormData } from "@/types/auth.types"
|
||||
|
||||
export default function SignupPage() {
|
||||
const [formData, setFormData] = useState<SignupFormData>({
|
||||
userSe: '',
|
||||
userId: '',
|
||||
userPassword: '',
|
||||
confirmPassword: '',
|
||||
userName: '',
|
||||
userPhone: '',
|
||||
userEmail: '',
|
||||
emailId: '',
|
||||
emailDomain: 'gmail.com',
|
||||
})
|
||||
const [verificationCode, setVerificationCode] = useState('')
|
||||
const [isEmailVerified, setIsEmailVerified] = useState(false)
|
||||
const [verifiedEmail, setVerifiedEmail] = useState('')
|
||||
const [isSendingCode, setIsSendingCode] = useState(false)
|
||||
const [isVerifyingCode, setIsVerifyingCode] = useState(false)
|
||||
const [isCodeSent, setIsCodeSent] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [emailCheckStatus, setEmailCheckStatus] = useState<'idle' | 'checking' | 'available' | 'unavailable'>('idle')
|
||||
const [emailCheckMessage, setEmailCheckMessage] = useState('')
|
||||
const router = useRouter()
|
||||
const { signup } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (formData.userEmail !== verifiedEmail) {
|
||||
setIsCodeSent(false)
|
||||
setIsEmailVerified(false)
|
||||
setVerificationCode('')
|
||||
}
|
||||
|
||||
if (!formData.emailId) {
|
||||
setEmailCheckStatus('idle')
|
||||
setEmailCheckMessage('이메일 아이디를 입력하세요')
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.emailDomain || (formData.emailDomain === '직접입력' && !formData.customDomain)) {
|
||||
setEmailCheckStatus('idle')
|
||||
setEmailCheckMessage('이메일 도메인을 선택하세요')
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.userEmail || !formData.userEmail.includes('@')) {
|
||||
setEmailCheckStatus('idle')
|
||||
setEmailCheckMessage('')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.userEmail === verifiedEmail && isEmailVerified) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
setEmailCheckStatus('checking')
|
||||
setEmailCheckMessage('이메일 확인 중...')
|
||||
|
||||
try {
|
||||
const result = await authApi.checkEmail(formData.userEmail)
|
||||
if (result.available) {
|
||||
setEmailCheckStatus('available')
|
||||
setEmailCheckMessage(result.message)
|
||||
} else {
|
||||
setEmailCheckStatus('unavailable')
|
||||
setEmailCheckMessage(result.message)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setEmailCheckStatus('idle')
|
||||
setEmailCheckMessage('')
|
||||
}
|
||||
}, 500)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [formData.emailId, formData.emailDomain, formData.customDomain, formData.userEmail, verifiedEmail, isEmailVerified])
|
||||
|
||||
const handleSendVerificationCode = async () => {
|
||||
if (!formData.userEmail) {
|
||||
setError('이메일을 입력해주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
setError('')
|
||||
setIsSendingCode(true)
|
||||
|
||||
try {
|
||||
const response = await authApi.sendSignupCode(formData.userEmail)
|
||||
const expiryMinutes = Math.floor((response.expiresIn || 180) / 60)
|
||||
setIsCodeSent(true)
|
||||
alert(`인증번호가 이메일로 발송되었습니다. (유효시간: ${expiryMinutes}분)`)
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('인증번호 발송에 실패했습니다.')
|
||||
}
|
||||
} finally {
|
||||
setIsSendingCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyCode = async () => {
|
||||
if (!verificationCode) {
|
||||
setError('인증번호를 입력해주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
setError('')
|
||||
setIsVerifyingCode(true)
|
||||
|
||||
try {
|
||||
const response = await authApi.verifySignupCode(formData.userEmail, verificationCode)
|
||||
if (response.verified) {
|
||||
setIsEmailVerified(true)
|
||||
setVerifiedEmail(formData.userEmail)
|
||||
alert('이메일 인증이 완료되었습니다!')
|
||||
} else {
|
||||
setError('인증번호가 일치하지 않습니다.')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('인증번호 확인에 실패했습니다.')
|
||||
}
|
||||
} finally {
|
||||
setIsVerifyingCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!formData.userSe) {
|
||||
setError('회원 유형을 선택해주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.userId.length < 4) {
|
||||
setError('아이디는 4자 이상이어야 합니다.')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.userName.length < 2) {
|
||||
setError('이름은 2자 이상이어야 합니다.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEmailVerified) {
|
||||
setError('이메일 인증을 완료해주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.userPassword.length < 8) {
|
||||
setError('비밀번호는 8자 이상이어야 합니다.')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.userPassword !== formData.confirmPassword) {
|
||||
setError('비밀번호가 일치하지 않습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.userSe !== 'FARM' && !formData.userInstName) {
|
||||
setError('컨설턴트/기관 회원은 기관명이 필수입니다.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await signup({
|
||||
userSe: formData.userSe as 'FARM' | 'CNSLT' | 'ORGAN',
|
||||
userId: formData.userId,
|
||||
userPassword: formData.userPassword,
|
||||
userName: formData.userName,
|
||||
userPhone: formData.userPhone,
|
||||
userEmail: formData.userEmail,
|
||||
userInstName: formData.userInstName || undefined,
|
||||
userBirth: formData.userBirth || undefined,
|
||||
userAddress: formData.userAddress || undefined,
|
||||
userBizNo: formData.userBizNo || undefined,
|
||||
})
|
||||
|
||||
alert('회원가입이 완료되었습니다!')
|
||||
router.push('/login')
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('알 수 없는 오류가 발생했습니다')
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
{/* 데스크톱: 왼쪽 로고 이미지 영역 */}
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image
|
||||
src="/logo-graphic.svg"
|
||||
alt="한우 유전능력 컨설팅 로고"
|
||||
fill
|
||||
className="object-contain p-16 -translate-y-12 translate-x-24"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 폼 영역 */}
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
{/* 모바일: 상단 로고 */}
|
||||
<div className="lg:hidden flex justify-center mb-6 mt-4">
|
||||
<Image
|
||||
src="/logo-graphic.svg"
|
||||
alt="한우 유전능력 컨설팅 로고"
|
||||
width={200}
|
||||
height={200}
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<SignupForm
|
||||
onSubmit={handleSignup}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
onSendVerificationCode={handleSendVerificationCode}
|
||||
isEmailVerified={isEmailVerified}
|
||||
isSendingCode={isSendingCode}
|
||||
isCodeSent={isCodeSent}
|
||||
verificationCode={verificationCode}
|
||||
setVerificationCode={setVerificationCode}
|
||||
onVerifyCode={handleVerifyCode}
|
||||
isVerifyingCode={isVerifyingCode}
|
||||
emailCheckStatus={emailCheckStatus}
|
||||
emailCheckMessage={emailCheckMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user