This commit is contained in:
2025-12-09 17:02:27 +09:00
parent 26f8e1dab2
commit 83127da569
275 changed files with 139682 additions and 1 deletions

41
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

8
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM node:24-alpine
WORKDIR /app
RUN apk add --no-cache curl
COPY package*.json .
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]
EXPOSE 3000

36
frontend/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

22
frontend/components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -0,0 +1,33 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
// 코드 스타일, 안티패턴, 잠재적 버그 검사을 위한 ESLint 설정
// 타입과 무관한 문제 찾기 위해 Next.js 권장 설정 사용
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
{
rules: {
"@typescript-eslint/no-explicit-any": "warn", // 끄기 warn → off
"@typescript-eslint/no-unused-vars": "warn",
},
},
];
export default eslintConfig;

21
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { NextConfig } from "next";
// Next.js 핵심 설정 파일, Next.js가 시작할 때 이 파일을 찾아서 읽음
// 여기에 Next.js 설정 옵션을 정의할 수 있음
const nextConfig: NextConfig = {
/* config options here
eslint: {
ignoreDuringBuilds: true, // 빌드 시 ESLint warning 무시
},
*/
async rewrites() {
return [
{
source: '/backend/api/:path*', // /api가 붙은 모든 요청
destination: 'http://backend:4000/:path*', // 백엔드 API로 요청
},
];
},
};
export default nextConfig;

8712
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

79
frontend/package.json Normal file
View File

@@ -0,0 +1,79 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack -H 0.0.0.0",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@nivo/sunburst": "^0.99.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.35.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"@types/react-window": "^1.8.8",
"axios": "^1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.24",
"lucide-react": "^0.545.0",
"next": "15.5.3",
"next-themes": "^0.4.6",
"pretendard": "^1.3.9",
"react": "19.1.0",
"react-circular-progressbar": "^2.2.0",
"react-countup": "^6.5.3",
"react-day-picker": "^9.11.1",
"react-dom": "19.1.0",
"react-icons": "^5.5.0",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "^2.2.2",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.3",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,8 @@
// package.json "tailwindcss": "^4", PostCSS 플러그인 방식
// src/app/globals.css:1에서 @import "tailwindcss" 사용
// 이 임포트는 PostCSS를 통해 처리되며, postcss.config.mjs가 필요
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

View File

@@ -0,0 +1,13 @@
<svg width="550" height="920" viewBox="0 0 550 920" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="550" height="920" fill="white"/>
<rect x="96" y="210" width="201" height="58" rx="29" fill="#C4001E"/>
<rect opacity="0.9" x="157" y="132" width="136" height="62" rx="31" transform="rotate(90 157 132)" fill="#1F3A8F"/>
<rect x="112" y="671" width="283" height="59" rx="29.5" fill="#C4001E"/>
<rect opacity="0.9" x="395" y="671" width="178" height="62" rx="31" transform="rotate(90 395 671)" fill="#1F3A8F"/>
<rect x="354" y="382" width="139" height="59" rx="29.5" fill="#C4001E"/>
<rect opacity="0.9" x="414" y="239" width="202" height="61" rx="30.5" transform="rotate(90 414 239)" fill="#1F3A8F"/>
<rect x="343" y="453" width="158" height="63" rx="31.5" transform="rotate(90 343 453)" fill="#C4001E"/>
<rect opacity="0.9" x="479" y="611" width="199" height="63" rx="31.5" transform="rotate(180 479 611)" fill="#1F3A8F"/>
<ellipse cx="166" cy="371.5" rx="38" ry="40.5" fill="#C4001E"/>
<ellipse cx="166" cy="512" rx="38" ry="39" fill="#1F3A8F"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

View 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"
}
]

File diff suppressed because it is too large Load Diff

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

View File

@@ -0,0 +1,7 @@
import { Button } from "@/components/ui/button";
export default function Page() {
return (
<Button>Click me</Button>
)
}

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

View File

@@ -0,0 +1,221 @@
"use client"
import { useState } from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { authApi } from "@/lib/api/auth.api"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
export function FindIdForm({
className,
...props
}: React.ComponentProps<"form">) {
const router = useRouter()
const [step, setStep] = useState<"email" | "verify" | "result">("email")
const [userName, setUserName] = useState("")
const [userEmail, setUserEmail] = useState("")
const [code, setCode] = useState("")
const [foundUserId, setFoundUserId] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [timer, setTimer] = useState(0)
// 인증번호 발송
const handleSendCode = async (e: React.FormEvent) => {
e.preventDefault()
console.log(' [아이디 찾기] 인증번호 발송 시도')
console.log(' - userName:', userName)
console.log(' - userEmail:', userEmail)
if (!userName || !userEmail) {
toast.error("이름과 이메일을 모두 입력해주세요")
return
}
setIsLoading(true)
try {
console.log(' API 호출:', { userName, userEmail })
const result = await authApi.sendFindIdCode(userName, userEmail)
console.log(' API 응답:', result)
toast.success(result.message)
setStep("verify")
setTimer(result.expiresIn) // (180초 = 3분) 제한 시간 설정========================
// 타이머 시작
const interval = setInterval(() => {
setTimer((prev) => {
if (prev <= 1) {
clearInterval(interval)
return 0
}
return prev - 1
})
}, 1000)
} catch (error: any) {
console.error(' API 에러:', error)
toast.error(error.message || "인증번호 발송에 실패했습니다")
} finally {
setIsLoading(false)
}
}
// 인증번호 확인
const handleVerifyCode = async (e: React.FormEvent) => {
e.preventDefault()
if (!code) {
toast.error("인증번호를 입력해주세요")
return
}
setIsLoading(true)
try {
const result = await authApi.verifyFindIdCode(userEmail, code)
toast.success(result.message)
setFoundUserId(result.maskedUserId || result.userId)
setStep("result")
} catch (error: any) {
toast.error(error.message || "인증번호 확인에 실패했습니다")
} finally {
setIsLoading(false)
}
}
// 로그인 페이지로 이동
const handleGoToLogin = () => {
router.push("/login")
}
// 타이머 포맷 (mm:ss)
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, "0")}`
}
return (
<form className={cn("flex flex-col gap-6", className)} {...props}>
<FieldGroup>
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground text-sm text-balance">
{step === "email" && "가입 시 등록한 이름과 이메일을 입력해주세요"}
{step === "verify" && "이메일로 전송된 인증번호를 입력해주세요"}
{step === "result" && "아이디 찾기가 완료되었습니다"}
</p>
</div>
{step === "email" && (
<>
<Field>
<FieldLabel htmlFor="name"></FieldLabel>
<Input
id="name"
type="text"
placeholder="홍길동"
value={userName}
onChange={(e) => setUserName(e.target.value)}
required
disabled={isLoading}
/>
</Field>
<Field>
<FieldLabel htmlFor="email"></FieldLabel>
<Input
id="email"
type="email"
placeholder="example@email.com"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
required
disabled={isLoading}
/>
<FieldDescription>
</FieldDescription>
</Field>
<Field>
<Button type="submit" onClick={handleSendCode} disabled={isLoading}>
{isLoading ? "발송 중..." : "인증번호 발송"}
</Button>
</Field>
</>
)}
{step === "verify" && (
<>
<Field>
<FieldLabel htmlFor="email"></FieldLabel>
<Input
id="email"
type="email"
value={userEmail}
disabled
/>
</Field>
<Field>
<FieldLabel htmlFor="code"></FieldLabel>
<Input
id="code"
type="text"
placeholder="6자리 인증번호"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
required
disabled={isLoading}
/>
<FieldDescription>
{timer > 0 ? `남은 시간: ${formatTime(timer)}` : "인증번호가 만료되었습니다"}
</FieldDescription>
</Field>
<Field>
<Button type="submit" onClick={handleVerifyCode} disabled={isLoading || timer === 0}>
{isLoading ? "확인 중..." : "인증번호 확인"}
</Button>
</Field>
<Field>
<Button type="button" variant="outline" onClick={() => setStep("email")} disabled={isLoading}>
</Button>
</Field>
</>
)}
{step === "result" && (
<>
<Field>
<div className="bg-muted rounded-lg p-6 text-center">
<p className="text-sm text-muted-foreground mb-2"> </p>
<p className="text-2xl font-bold text-primary">{foundUserId}</p>
<p className="text-sm text-muted-foreground mt-2"></p>
</div>
</Field>
<Field>
<Button type="button" onClick={handleGoToLogin}>
</Button>
</Field>
<Field>
<Button type="button" variant="outline" onClick={() => router.push("/findpw")}>
</Button>
</Field>
</>
)}
<Field>
<FieldDescription className="px-6 text-center">
? <a href="/register" className="underline"> </a>
</FieldDescription>
</Field>
</FieldGroup>
</form>
)
}

View File

@@ -0,0 +1,213 @@
"use client"
import { useState } from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { authApi } from "@/lib/api/auth.api"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
export function FindIdForm({
className,
...props
}: React.ComponentProps<"form">) {
const router = useRouter()
const [step, setStep] = useState<"email" | "verify" | "result">("email")
const [userName, setUserName] = useState("")
const [userEmail, setUserEmail] = useState("")
const [code, setCode] = useState("")
const [foundUserId, setFoundUserId] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [timer, setTimer] = useState(0)
const handleSendCode = async (e: React.FormEvent) => {
e.preventDefault()
if (!userName || !userEmail) {
toast.error("이름과 이메일을 모두 입력해주세요")
return
}
setIsLoading(true)
try {
const result = await authApi.sendFindIdCode(userName, userEmail)
toast.success(result.message)
setStep("verify")
setTimer(result.expiresIn)
const interval = setInterval(() => {
setTimer((prev) => {
if (prev <= 1) {
clearInterval(interval)
return 0
}
return prev - 1
})
}, 1000)
} catch (error: any) {
toast.error(error.message || "인증번호 발송에 실패했습니다")
} finally {
setIsLoading(false)
}
}
const handleVerifyCode = async (e: React.FormEvent) => {
e.preventDefault()
if (!code) {
toast.error("인증번호를 입력해주세요")
return
}
setIsLoading(true)
try {
const result = await authApi.verifyFindIdCode(userEmail, code)
toast.success(result.message)
setFoundUserId(result.maskedUserId || result.userId)
setStep("result")
} catch (error: any) {
toast.error(error.message || "인증번호 확인에 실패했습니다")
} finally {
setIsLoading(false)
}
}
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return m + ":" + String(s).padStart(2, "0")
}
return (
<form className={cn("flex flex-col", className)} {...props}>
<FieldGroup>
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground text-sm text-balance">
{step === "email" && "가입 시 등록한 이름과 이메일을 입력해주세요"}
{step === "verify" && "이메일로 전송된 인증번호를 입력해주세요"}
{step === "result" && "아이디 찾기가 완료되었습니다"}
</p>
</div>
{step === "email" && (
<>
<Field>
<FieldLabel htmlFor="name"></FieldLabel>
<Input
id="name"
type="text"
placeholder="홍길동"
value={userName}
onChange={(e) => setUserName(e.target.value)}
required
disabled={isLoading}
/>
</Field>
<Field>
<FieldLabel htmlFor="email"></FieldLabel>
<Input
id="email"
type="email"
placeholder="example@email.com"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
required
disabled={isLoading}
/>
<FieldDescription>
</FieldDescription>
</Field>
<Field>
<Button type="submit" onClick={handleSendCode} disabled={isLoading}>
{isLoading ? "발송 중..." : "인증번호 발송"}
</Button>
</Field>
</>
)}
{step === "verify" && (
<>
<Field>
<FieldLabel htmlFor="email"></FieldLabel>
<Input
id="email"
type="email"
value={userEmail}
disabled
/>
</Field>
<Field>
<FieldLabel htmlFor="code"></FieldLabel>
<Input
id="code"
type="text"
placeholder="6자리 인증번호"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
required
disabled={isLoading}
/>
<FieldDescription>
{timer > 0 ? "남은 시간: " + formatTime(timer) : "인증번호가 만료되었습니다"}
</FieldDescription>
</Field>
<Field>
<Button type="submit" onClick={handleVerifyCode} disabled={isLoading || timer === 0}>
{isLoading ? "확인 중..." : "인증번호 확인"}
</Button>
</Field>
<Field>
<Button type="button" variant="outline" onClick={() => setStep("email")} disabled={isLoading}>
</Button>
</Field>
</>
)}
{step === "result" && (
<>
<Field>
<div className="bg-muted rounded-lg p-6 text-center">
<p className="text-sm text-muted-foreground mb-2"> </p>
<p className="text-2xl font-bold text-primary">{foundUserId}</p>
<p className="text-sm text-muted-foreground mt-2"></p>
</div>
</Field>
<Field>
<Button type="button" onClick={() => router.push("/login")}>
</Button>
</Field>
<Field>
<Button type="button" variant="outline" onClick={() => router.push("/findpw")}>
</Button>
</Field>
</>
)}
<Field className="-mt-5">
<Button variant="outline" type="button" onClick={() => router.push("/login")} className="w-full border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300">
</Button>
</Field>
<FieldSeparator> ?</FieldSeparator>
<Field>
<Button variant="outline" type="button" onClick={() => router.push("/signup")} className="w-full border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300">
</Button>
</Field>
</FieldGroup>
</form>
)
}

View File

@@ -0,0 +1,323 @@
"use client"
import { useState } from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { authApi } from "@/lib/api/auth.api"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { Eye, EyeOff } from "lucide-react"
export function FindPwForm({
className,
...props
}: React.ComponentProps<"form">) {
const router = useRouter()
const [step, setStep] = useState<"info" | "verify" | "reset" | "complete">("info")
const [userId, setUserId] = useState("")
const [userEmail, setUserEmail] = useState("")
const [code, setCode] = useState("")
const [resetToken, setResetToken] = useState("")
const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [timer, setTimer] = useState(0)
// 인증번호 발송
const handleSendCode = async (e: React.FormEvent) => {
e.preventDefault()
if (!userId || !userEmail) {
toast.error("아이디와 이메일을 모두 입력해주세요")
return
}
setIsLoading(true)
try {
const result = await authApi.sendResetPasswordCode(userId, userEmail)
toast.success(result.message)
setStep("verify")
setTimer(result.expiresIn) // 이미 초 단위로 전달됨 (180초 = 3분)
// 타이머 시작
const interval = setInterval(() => {
setTimer((prev) => {
if (prev <= 1) {
clearInterval(interval)
return 0
}
return prev - 1
})
}, 1000)
} catch (error: any) {
toast.error(error.message || "인증번호 발송에 실패했습니다")
} finally {
setIsLoading(false)
}
}
// 인증번호 확인
const handleVerifyCode = async (e: React.FormEvent) => {
e.preventDefault()
if (!code) {
toast.error("인증번호를 입력해주세요")
return
}
setIsLoading(true)
try {
const result = await authApi.verifyResetPasswordCode(userId, userEmail, code)
toast.success(result.message)
setResetToken(result.resetToken)
setStep("reset")
} catch (error: any) {
toast.error(error.message || "인증번호 확인에 실패했습니다")
} finally {
setIsLoading(false)
}
}
// 비밀번호 재설정
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault()
if (!newPassword || !confirmPassword) {
toast.error("비밀번호를 입력해주세요")
return
}
if (newPassword !== confirmPassword) {
toast.error("비밀번호가 일치하지 않습니다")
return
}
if (newPassword.length < 8) {
toast.error("비밀번호는 8자 이상이어야 합니다")
return
}
setIsLoading(true)
try {
const result = await authApi.resetPassword(resetToken, newPassword)
toast.success(result.message)
setStep("complete")
} catch (error: any) {
toast.error(error.message || "비밀번호 재설정에 실패했습니다")
} finally {
setIsLoading(false)
}
}
// 로그인 페이지로 이동
const handleGoToLogin = () => {
router.push("/login")
}
// 타이머 포맷 (mm:ss)
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, "0")}`
}
return (
<form className={cn("flex flex-col", className)} {...props}>
<FieldGroup>
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground text-sm text-balance">
{step === "info" && "아이디와 이메일을 입력해주세요"}
{step === "verify" && "이메일로 전송된 인증번호를 입력해주세요"}
{step === "reset" && "새로운 비밀번호를 설정해주세요"}
{step === "complete" && "비밀번호 재설정이 완료되었습니다"}
</p>
</div>
{step === "info" && (
<>
<Field>
<FieldLabel htmlFor="userId"></FieldLabel>
<Input
id="userId"
type="text"
placeholder="아이디를 입력하세요"
value={userId}
onChange={(e) => setUserId(e.target.value)}
required
disabled={isLoading}
/>
</Field>
<Field>
<FieldLabel htmlFor="email"></FieldLabel>
<Input
id="email"
type="email"
placeholder="example@email.com"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
required
disabled={isLoading}
/>
<FieldDescription>
</FieldDescription>
</Field>
<Field>
<Button type="submit" onClick={handleSendCode} disabled={isLoading}>
{isLoading ? "발송 중..." : "인증번호 발송"}
</Button>
</Field>
</>
)}
{step === "verify" && (
<>
<Field>
<FieldLabel htmlFor="userId"></FieldLabel>
<Input
id="userId"
type="text"
value={userId}
disabled
/>
</Field>
<Field>
<FieldLabel htmlFor="email"></FieldLabel>
<Input
id="email"
type="email"
value={userEmail}
disabled
/>
</Field>
<Field>
<FieldLabel htmlFor="code"></FieldLabel>
<Input
id="code"
type="text"
placeholder="6자리 인증번호"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
required
disabled={isLoading}
/>
<FieldDescription>
{timer > 0 ? `남은 시간: ${formatTime(timer)}` : "인증번호가 만료되었습니다"}
</FieldDescription>
</Field>
<Field>
<Button type="submit" onClick={handleVerifyCode} disabled={isLoading || timer === 0}>
{isLoading ? "확인 중..." : "인증번호 확인"}
</Button>
</Field>
<Field>
<Button type="button" variant="outline" onClick={() => setStep("info")} disabled={isLoading}>
</Button>
</Field>
</>
)}
{step === "reset" && (
<>
<Field>
<FieldLabel htmlFor="newPassword"> </FieldLabel>
<div className="relative">
<Input
id="newPassword"
type={showPassword ? "text" : "password"}
placeholder="8자 이상 입력하세요"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<FieldDescription>
8
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="confirmPassword"> </FieldLabel>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
placeholder="비밀번호를 다시 입력하세요"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{newPassword && confirmPassword && newPassword !== confirmPassword && (
<FieldDescription className="text-destructive">
</FieldDescription>
)}
</Field>
<Field>
<Button type="submit" onClick={handleResetPassword} disabled={isLoading}>
{isLoading ? "변경 중..." : "비밀번호 변경"}
</Button>
</Field>
</>
)}
{step === "complete" && (
<>
<Field>
<div className="bg-muted rounded-lg p-6 text-center">
<p className="text-lg font-semibold text-primary mb-2"> </p>
<p className="text-sm text-muted-foreground">
</p>
</div>
</Field>
<Field>
<Button type="button" onClick={handleGoToLogin}>
</Button>
</Field>
</>
)}
<Field className="-mt-5">
<Button variant="outline" type="button" onClick={() => router.push("/login")} className="w-full border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300">
</Button>
</Field>
<FieldSeparator> ?</FieldSeparator>
<Field>
<Button variant="outline" type="button" onClick={() => router.push("/signup")} className="w-full border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300">
</Button>
</Field>
</FieldGroup>
</form>
)
}

View File

@@ -0,0 +1,112 @@
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
export function LoginForm({
className,
onSubmit,
userId,
setUserId,
userPassword,
setUserPassword,
error,
isLoading,
...props
}: React.ComponentProps<"form"> & {
onSubmit: (e: React.FormEvent) => void;
userId: string;
setUserId: (value: string) => void;
userPassword: string;
setUserPassword: (value: string) => void;
error?: string;
isLoading?: boolean;
}) {
const router = useRouter();
return (
<form className={cn("flex flex-col gap-4", className)} onSubmit={onSubmit} {...props}>
<FieldGroup>
<div className="flex flex-col items-center gap-1 text-center mb-2">
<h1 className="text-xl font-bold"></h1>
<p className="text-muted-foreground text-sm text-balance">
</p>
</div>
<Field>
<FieldLabel htmlFor="userId"></FieldLabel>
<Input
id="userId"
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="아이디를 입력하세요"
disabled={isLoading}
required
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password"></FieldLabel>
</div>
<Input
id="password"
type="password"
value={userPassword}
onChange={(e) => setUserPassword(e.target.value)}
placeholder="비밀번호를 입력하세요"
disabled={isLoading}
required
/>
</Field>
{error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-600">
{error}
</div>
)}
<Field>
<Button
type="submit"
className="w-full bg-primary hover:bg-primary/90 text-white shadow-lg hover:shadow-xl transition-all duration-300"
disabled={isLoading}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isLoading ? '로그인 중...' : '로그인'}
</Button>
</Field>
<FieldSeparator> ?</FieldSeparator>
<Field>
<Button
variant="outline"
type="button"
onClick={() => router.push('/signup')}
disabled={isLoading}
className="w-full border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground hover:border-transparent transition-all duration-300"
>
</Button>
<FieldDescription className="text-center mt-2">
<a href="/findid" className="text-sm hover:underline underline-offset-4">
</a>
{" "}|{" "}
<a
href="/findpw"
className="text-sm hover:underline underline-offset-4">
</a>
</FieldDescription>
</Field>
</FieldGroup>
</form>
)
}

View File

@@ -0,0 +1,394 @@
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useRouter } from "next/navigation"
import { Loader2, CheckCircle2, XCircle } from "lucide-react"
import { SignupFormData } from "@/types/auth.types"
import { Dispatch, SetStateAction } from "react"
interface SignupFormProps extends React.ComponentProps<"form"> {
onSubmit: (e: React.FormEvent) => void;
formData: SignupFormData;
setFormData: Dispatch<SetStateAction<SignupFormData>>;
error?: string;
isLoading?: boolean;
onSendVerificationCode: () => void;
isEmailVerified: boolean;
isSendingCode: boolean;
isCodeSent: boolean;
verificationCode: string;
setVerificationCode: (code: string) => void;
onVerifyCode: () => void;
isVerifyingCode: boolean;
emailCheckStatus: 'idle' | 'checking' | 'available' | 'unavailable';
emailCheckMessage: string;
}
export function SignupForm({
className,
onSubmit,
formData,
setFormData,
error,
isLoading,
onSendVerificationCode,
isEmailVerified,
isSendingCode,
isCodeSent,
verificationCode,
setVerificationCode,
onVerifyCode,
isVerifyingCode,
emailCheckStatus,
emailCheckMessage,
...props
}: SignupFormProps) {
const router = useRouter();
const handleChange = (field: keyof SignupFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const emailDomains = [
'gmail.com',
'naver.com',
'daum.net',
'hanmail.net',
'nate.com',
'kakao.com',
'직접입력',
];
return (
<form className={cn("flex flex-col gap-6", className)} onSubmit={onSubmit} {...props}>
<FieldGroup>
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground text-sm text-balance">
</p>
</div>
<Field>
<FieldLabel htmlFor="userSe"> *</FieldLabel>
<Select
value={formData.userSe}
onValueChange={(value) => handleChange('userSe', value)}
disabled={isLoading}
required
>
<SelectTrigger>
<SelectValue placeholder="회원 유형을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="FARM"></SelectItem>
<SelectItem value="CNSLT"></SelectItem>
<SelectItem value="ORGAN"></SelectItem>
</SelectContent>
</Select>
<FieldDescription> .</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="userId"> *</FieldLabel>
<Input
id="userId"
type="text"
value={formData.userId}
onChange={(e) => handleChange('userId', e.target.value)}
placeholder="아이디를 입력하세요 (4자 이상)"
disabled={isLoading}
required
/>
<FieldDescription>4 .</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="userName"> *</FieldLabel>
<Input
id="userName"
type="text"
value={formData.userName}
onChange={(e) => handleChange('userName', e.target.value)}
placeholder="이름을 입력하세요 (2자 이상)"
disabled={isLoading}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="userEmail"> *</FieldLabel>
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<Input
id="emailId"
type="text"
value={formData.emailId}
onChange={(e) => {
const newEmailId = e.target.value;
setFormData((prev) => {
const domain = prev.emailDomain === '직접입력' ? '' : prev.emailDomain;
return {
...prev,
emailId: newEmailId,
userEmail: domain ? `${newEmailId}@${domain}` : newEmailId,
};
});
}}
placeholder="이메일 아이디"
disabled={isLoading || isEmailVerified}
required
className="flex-1"
/>
<span className="text-muted-foreground">@</span>
{formData.emailDomain === '직접입력' ? (
<div className="flex flex-1">
<Input
id="customDomain"
type="text"
placeholder="도메인 주소"
value={formData.customDomain || ''}
onChange={(e) => {
const newDomain = e.target.value;
setFormData((prev) => ({
...prev,
customDomain: newDomain,
userEmail: `${prev.emailId}@${newDomain}`,
}));
}}
disabled={isLoading || isEmailVerified}
required
className="flex-1 rounded-r-none border-r-0 h-9 !text-sm px-3 py-2"
/>
<Select
value=""
onValueChange={(value) => {
setFormData((prev) => ({
...prev,
emailDomain: value,
customDomain: '',
userEmail: `${prev.emailId}@${value}`,
}));
}}
disabled={isLoading || isEmailVerified}
>
<SelectTrigger className="w-[50px] rounded-l-none -ml-px px-2 h-9 [&>svg]:size-4">
<SelectValue />
</SelectTrigger>
<SelectContent>
{emailDomains.map((domain) => (
<SelectItem key={domain} value={domain}>
{domain}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<Select
value={formData.emailDomain || undefined}
onValueChange={(value) => {
if (value !== '직접입력') {
setFormData((prev) => ({
...prev,
emailDomain: value,
userEmail: `${prev.emailId}@${value}`,
}));
} else {
setFormData((prev) => ({
...prev,
emailDomain: value,
customDomain: '',
userEmail: prev.emailId,
}));
}
}}
disabled={isLoading || isEmailVerified}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="도메인 선택" />
</SelectTrigger>
<SelectContent>
{emailDomains.map((domain) => (
<SelectItem key={domain} value={domain}>
{domain}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<Button
type="button"
onClick={onSendVerificationCode}
disabled={
!formData.emailId ||
(formData.emailDomain === '직접입력' ? !formData.customDomain : !formData.emailDomain) ||
isLoading ||
isEmailVerified ||
isSendingCode ||
isCodeSent ||
emailCheckStatus === 'unavailable' ||
emailCheckStatus === 'checking'
}
variant={isCodeSent || isEmailVerified ? "secondary" : "outline"}
className="w-full"
>
{isSendingCode && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEmailVerified ? '인증완료' : isSendingCode ? '발송중...' : isCodeSent ? '발송완료' : '인증번호 발송'}
</Button>
</div>
{emailCheckMessage && !isEmailVerified && (
<div className={cn(
"flex items-center gap-2 text-sm",
emailCheckStatus === 'checking' && "text-muted-foreground",
emailCheckStatus === 'available' && "text-green-600",
emailCheckStatus === 'unavailable' && "text-red-600"
)}>
{emailCheckStatus === 'checking' && <Loader2 className="h-4 w-4 animate-spin" />}
{emailCheckStatus === 'available' && <CheckCircle2 className="h-4 w-4" />}
{emailCheckStatus === 'unavailable' && <XCircle className="h-4 w-4" />}
<span>{emailCheckMessage}</span>
</div>
)}
{isEmailVerified && (
<FieldDescription className="text-green-600">
.
</FieldDescription>
)}
</Field>
{!isEmailVerified && isCodeSent && formData.userEmail && (
<Field>
<FieldLabel htmlFor="verificationCode"></FieldLabel>
<div className="flex gap-2">
<Input
id="verificationCode"
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="인증번호 6자리"
disabled={isLoading || isEmailVerified}
maxLength={6}
className="flex-1"
/>
<Button
type="button"
onClick={onVerifyCode}
disabled={!verificationCode || isLoading || isEmailVerified || isVerifyingCode}
variant="outline"
>
{isVerifyingCode && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isVerifyingCode ? '확인중...' : '확인'}
</Button>
</div>
<FieldDescription> 6 .</FieldDescription>
</Field>
)}
<Field>
<FieldLabel htmlFor="userPhone"> *</FieldLabel>
<Input
id="userPhone"
type="tel"
value={formData.userPhone}
onChange={(e) => handleChange('userPhone', e.target.value)}
placeholder="010-0000-0000"
disabled={isLoading}
required
/>
</Field>
{formData.userSe !== 'FARM' && formData.userSe && (
<Field>
<FieldLabel htmlFor="userInstName"> *</FieldLabel>
<Input
id="userInstName"
type="text"
value={formData.userInstName || ''}
onChange={(e) => handleChange('userInstName', e.target.value)}
placeholder="소속 기관명을 입력하세요"
disabled={isLoading}
required={formData.userSe !== 'FARM'}
/>
<FieldDescription>/ .</FieldDescription>
</Field>
)}
<Field>
<FieldLabel htmlFor="userPassword"> *</FieldLabel>
<Input
id="userPassword"
type="password"
value={formData.userPassword}
onChange={(e) => handleChange('userPassword', e.target.value)}
placeholder="비밀번호를 입력하세요 (8자 이상)"
disabled={isLoading}
required
/>
<FieldDescription>8 .</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="confirmPassword"> *</FieldLabel>
<Input
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)}
placeholder="비밀번호를 다시 입력하세요"
disabled={isLoading}
required
/>
</Field>
{error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-600">
{error}
</div>
)}
<Field>
<Button
type="submit"
className="w-full"
disabled={isLoading || !isEmailVerified}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isLoading ? '회원가입 중...' : '회원가입'}
</Button>
{!isEmailVerified && (
<FieldDescription className="text-center text-amber-600">
.
</FieldDescription>
)}
</Field>
<Field>
<Button
variant="outline"
type="button"
onClick={() => router.push('/login')}
disabled={isLoading}
className="w-full"
>
</Button>
</Field>
</FieldGroup>
</form>
)
}

View File

@@ -0,0 +1,4 @@
// ============================================
// 차트 컴포넌트
// ============================================
export { MptGaugeBar } from './mpt-gauge-bar';

View File

@@ -0,0 +1,261 @@
'use client'
/**
* MPT 혈액대사검사 게이지/바 차트
* 정상 범위 대비 현재 값의 위치를 시각적으로 표시
*/
interface MptGaugeBarProps {
name: string
value: number
unit: string
lowerLimit: number | null
upperLimit: number | null
category: string
regionAverage?: number // 보은군 평균 (선택)
}
export function MptGaugeBar({
name,
value,
unit,
lowerLimit,
upperLimit,
category,
regionAverage
}: MptGaugeBarProps) {
// 상태 계산
const getStatus = (): 'low' | 'normal' | 'high' => {
if (upperLimit !== null && value > upperLimit) return 'high'
if (lowerLimit !== null && value < lowerLimit) return 'low'
return 'normal'
}
const status = getStatus()
// 범위와 값 기반 시각화 계산
const calculateVisualization = () => {
if (lowerLimit === null || upperLimit === null) {
// 범위 정보 없음 - 단순 값 표시
return { position: 50, hasRange: false }
}
// 모든 값 수집 (현재값, 보은군평균, 정상범위)
const allValues = [value, lowerLimit, upperLimit]
if (regionAverage !== undefined) {
allValues.push(regionAverage)
}
const dataMin = Math.min(...allValues)
const dataMax = Math.max(...allValues)
const dataRange = dataMax - dataMin
// 데이터 범위의 20% 패딩 추가
const padding = dataRange * 0.2
const chartMin = dataMin - padding
const chartMax = dataMax + padding
const chartRange = chartMax - chartMin
// 값의 위치 계산 (0-100%)
let position = ((value - chartMin) / chartRange) * 100
position = Math.max(0, Math.min(100, position)) // 0-100% 범위로 제한
// 정상범위 시작/끝 위치
const normalStart = ((lowerLimit - chartMin) / chartRange) * 100
const normalEnd = ((upperLimit - chartMin) / chartRange) * 100
return {
position,
normalStart,
normalEnd,
hasRange: true,
chartMin,
chartMax,
chartRange,
}
}
const viz = calculateVisualization()
// 카테고리별 색상 (제거 - 사용 안 함)
// 상태별 색상 (부드러운 톤)
const getStatusColor = () => {
switch (status) {
case 'high': return {
bg: 'bg-gradient-to-r from-red-400 to-red-500',
text: 'text-red-700',
badgeBg: 'bg-red-50',
badgeBorder: 'border-red-200',
barBg: 'linear-gradient(to right, #f87171, #ef4444)' // red-400 to red-500
}
case 'low': return {
bg: 'bg-gradient-to-r from-blue-400 to-blue-500',
text: 'text-blue-700',
badgeBg: 'bg-blue-50',
badgeBorder: 'border-blue-200',
barBg: 'linear-gradient(to right, #60a5fa, #3b82f6)' // blue-400 to blue-500
}
case 'normal': return {
bg: 'bg-gradient-to-r from-green-400 to-green-500',
text: 'text-green-700',
badgeBg: 'bg-green-50',
badgeBorder: 'border-green-200',
barBg: 'linear-gradient(to right, #4ade80, #22c55e)' // green-400 to green-500
}
}
}
const getStatusText = () => {
switch (status) {
case 'high': return '높음 ↑'
case 'low': return '낮음 ↓'
case 'normal': return '정상'
}
}
const statusColors = getStatusColor()
return (
<div className="p-3 md:p-3.5 rounded-xl border-2 border-slate-200 bg-white shadow-sm hover:shadow-md transition-shadow">
{/* 항목명과 현재값 */}
<div className="flex items-center justify-between mb-2 md:mb-2.5">
<span className="text-xs md:text-sm font-bold text-slate-800">{name}</span>
<div className="flex items-baseline gap-1">
<span className={`text-lg md:text-xl font-bold ${statusColors.text}`}>
{value.toFixed(1)}
</span>
<span className="text-xs md:text-sm font-semibold text-slate-600">{unit}</span>
</div>
</div>
{/* 게이지 바 */}
{viz.hasRange ? (
<div className="mb-2 md:mb-2.5">
{/* 현재값 및 보은군 평균 표시 (바 위) */}
<div className="relative h-10 md:h-11 mb-1">
{/* 보은군 평균 표시 */}
{regionAverage !== undefined && viz.hasRange && (() => {
const regionPosition = ((regionAverage - viz.chartMin!) / viz.chartRange!) * 100;
return (
<div
className="absolute -translate-x-1/2 transition-all duration-500"
style={{ left: `${Math.max(0, Math.min(100, regionPosition))}%`, top: '0px' }}
>
<div className="px-1.5 md:px-2 py-0.5 md:py-1 rounded text-[10px] md:text-xs font-semibold whitespace-nowrap bg-slate-100 text-slate-700 border border-slate-300 shadow-sm">
: {regionAverage.toFixed(1)}
</div>
{/* 화살표 */}
<div
className="w-0 h-0 mx-auto"
style={{
borderLeft: '4px solid transparent',
borderRight: '4px solid transparent',
borderTop: '4px solid #94a3b8',
}}
/>
</div>
);
})()}
{/* 현재값 표시 */}
<div
className="absolute -translate-x-1/2 transition-all duration-500"
style={{ left: `${viz.position}%`, top: regionAverage !== undefined ? '24px' : '0px' }}
>
<div className={`px-1.5 md:px-2 py-0.5 md:py-1 rounded text-[10px] md:text-xs font-bold whitespace-nowrap shadow-sm border ${
status === 'high' ? 'bg-red-500 text-white border-red-600' :
status === 'low' ? 'bg-blue-500 text-white border-blue-600' :
'bg-green-500 text-white border-green-600'
}`}>
{value.toFixed(1)}
{regionAverage !== undefined && Math.abs(value - regionAverage) > 0.1 && (
<span className="ml-0.5">
({value > regionAverage ? '+' : ''}{(value - regionAverage).toFixed(1)})
</span>
)}
</div>
{/* 화살표 */}
<div
className="w-0 h-0 mx-auto"
style={{
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderTop: `5px solid ${
status === 'high' ? '#ef4444' :
status === 'low' ? '#3b82f6' : '#22c55e'
}`,
}}
/>
</div>
</div>
<div className="relative h-3 md:h-4 bg-slate-100 rounded-full overflow-hidden border-2 border-slate-200 shadow-inner">
{/* 정상 범위 영역 */}
<div
className="absolute h-full bg-green-100/60"
style={{
left: `${viz.normalStart}%`,
width: `${viz.normalEnd! - viz.normalStart!}%`,
}}
/>
{/* 보은군 평균 인디케이터 (회색 실선) */}
{regionAverage !== undefined && viz.hasRange && (() => {
const regionPosition = ((regionAverage - viz.chartMin!) / viz.chartRange!) * 100;
return (
<div
className="absolute top-0 h-full w-[3px] transition-all duration-500 z-[9]"
style={{
left: `${Math.max(0, Math.min(100, regionPosition))}%`,
background: '#94a3b8',
}}
/>
);
})()}
{/* 현재 값 인디케이터 */}
<div
className="absolute top-0 h-full w-1.5 md:w-2 transition-all duration-500 shadow-md z-10 rounded-full"
style={{
left: `${viz.position}%`,
background: statusColors.barBg,
}}
/>
</div>
{/* 정상 범위 수치 표기 (바 양옆) */}
<div className="flex items-center justify-between mt-1.5 md:mt-2 px-0.5">
<div className="flex flex-col items-start">
<span className="text-[10px] md:text-xs text-slate-500 font-semibold"></span>
<span className="text-xs md:text-sm font-bold text-slate-700">{lowerLimit}</span>
</div>
<div className="flex items-center gap-1">
<span className={`px-1.5 md:px-2 py-0.5 md:py-1 rounded text-[10px] md:text-xs font-bold border-2 ${statusColors.badgeBg} ${statusColors.text} ${statusColors.badgeBorder}`}>
{getStatusText()}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-[10px] md:text-xs text-slate-500 font-semibold"></span>
<span className="text-xs md:text-sm font-bold text-slate-700">{upperLimit}</span>
</div>
</div>
</div>
) : (
<div className="mb-2">
<div className="h-3 md:h-4 bg-slate-100 rounded-full overflow-hidden border-2 border-slate-200 shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500`}
style={{ width: '100%', background: statusColors.barBg }}
/>
</div>
<div className="mt-2 text-center">
<span className={`px-2 md:px-2.5 py-1 md:py-1.5 rounded text-[10px] md:text-xs font-bold border-2 ${statusColors.badgeBg} ${statusColors.text} ${statusColors.badgeBorder}`}>
{getStatusText()}
</span>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client';
import { useEffect, useState } from 'react';
export function AnimatedDesktop() {
const [svgContent, setSvgContent] = useState('');
useEffect(() => {
// SVG 파일을 fetch해서 내용을 가져옴
fetch('/images/Desktop_SVG.svg')
.then(res => res.text())
.then(text => {
// SVG 내용에 CSS 애니메이션을 추가
const parser = new DOMParser();
const svgDoc = parser.parseFromString(text, 'image/svg+xml');
const svg = svgDoc.documentElement;
// style 태그 추가
const style = svgDoc.createElementNS('http://www.w3.org/2000/svg', 'style');
style.textContent = `
@keyframes rotate-circle {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.rotating-element {
animation: rotate-circle 4s linear infinite;
transform-origin: center;
transform-box: fill-box;
}
`;
svg.insertBefore(style, svg.firstChild);
// 회전시킬 요소 찾기 (fill="#38BDD4" and transform="translate(813,386)")
const paths = svg.querySelectorAll('path');
paths.forEach(path => {
const fill = path.getAttribute('fill');
const transform = path.getAttribute('transform');
if (fill === '#38BDD4' && transform && transform.includes('translate(813,386)')) {
path.classList.add('rotating-element');
}
});
// 수정된 SVG를 문자열로 변환
const serializer = new XMLSerializer();
const modifiedSvg = serializer.serializeToString(svg);
setSvgContent(modifiedSvg);
})
.catch(console.error);
}, []);
return (
<div
className="relative w-full h-full flex items-center justify-center"
dangerouslySetInnerHTML={{ __html: svgContent }}
/>
);
}

View File

@@ -0,0 +1,91 @@
/**
* 개체번호 표시 컴포넌트
* 형식: "KOR 001 1223 3440 1" (3-4-4-1 띄어쓰기)
* cowShortNo(요약번호 4자리)를 강조 표시
*/
interface CowNumberDisplayProps {
cowId: string | number // 개체식별번호 (KOR로 시작하는 문자열 또는 숫자)
cowShortNo?: string
className?: string
variant?: 'default' | 'light' | 'highlight' // highlight: 배경색 강조
format?: 'full' | 'noKor' | 'shortOnly' // full: KOR 포함, noKor: KOR 제외, shortOnly: 4자리만
}
/**
* cowId에서 cowShortNo를 자동 계산하는 함수
* 규칙: 숫자만 추출 → 마지막 자리 제외 → 뒤에서 4자리
* 예: 001122334401 → 2334
*/
function extractShortNo(cowId: string): string {
const digits = cowId.replace(/\D/g, '')
if (digits.length < 5) return ''
return digits.slice(-5, -1)
}
export function CowNumberDisplay({ cowId, cowShortNo, className = '', variant = 'default', format = 'full' }: CowNumberDisplayProps) {
const cowIdStr = String(cowId || '')
const cleaned = cowIdStr.replace(/\D/g, '')
if (cleaned.length !== 12) {
return <span className={className}>{cowId}</span>
}
const shortNo = cowShortNo || extractShortNo(cowIdStr)
const part1 = cleaned.slice(0, 3)
const part2 = cleaned.slice(3, 7)
const part3 = cleaned.slice(7, 11)
const part4 = cleaned.slice(11)
// variant에 따른 강조 스타일
const getHighlightClass = () => {
switch (variant) {
case 'light':
return 'text-white font-bold'
case 'highlight':
return 'bg-primary/15 text-primary font-bold px-1.5 py-0.5 rounded'
default:
return 'bg-blue-100 text-blue-700 font-bold px-1.5 py-0.5 rounded'
}
}
// shortOnly: 4자리만 표시
if (format === 'shortOnly') {
return (
<span className={className}>
<span className={getHighlightClass()}>{part3}</span>
</span>
)
}
// noKor: KOR 제외
if (format === 'noKor') {
if (shortNo && shortNo.length === 4) {
return (
<span className={className}>
{part1} {part2} <span className={getHighlightClass()}>{part3}</span> {part4}
</span>
)
}
return (
<span className={className}>
{part1} {part2} {part3} {part4}
</span>
)
}
// full: KOR 포함 (기본값)
if (shortNo && shortNo.length === 4) {
return (
<span className={className}>
KOR {part1} {part2} <span className={getHighlightClass()}>{part3}</span> {part4}
</span>
)
}
return (
<span className={className}>
KOR {part1} {part2} {part3} {part4}
</span>
)
}

View File

@@ -0,0 +1,807 @@
"use client"
import * as React from "react"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
type UniqueIdentifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconCircleCheckFilled,
IconDotsVertical,
IconGripVertical,
IconLayoutColumns,
IconLoader,
IconPlus,
IconTrendingUp,
} from "@tabler/icons-react"
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
Row,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { toast } from "sonner"
import { z } from "zod"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { Checkbox } from "@/components/ui/checkbox"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
export const schema = z.object({
id: z.number(),
header: z.string(),
type: z.string(),
status: z.string(),
target: z.string(),
limit: z.string(),
reviewer: z.string(),
})
// Create a separate component for the drag handle
function DragHandle({ id }: { id: number }) {
const { attributes, listeners } = useSortable({
id,
})
return (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="icon"
className="text-muted-foreground size-7 hover:bg-transparent"
>
<IconGripVertical className="text-muted-foreground size-3" />
<span className="sr-only">Drag to reorder</span>
</Button>
)
}
const columns: ColumnDef<z.infer<typeof schema>>[] = [
{
id: "drag",
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
},
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "header",
header: "Header",
cell: ({ row }) => {
return <TableCellViewer item={row.original} />
},
enableHiding: false,
},
{
accessorKey: "type",
header: "Section Type",
cell: ({ row }) => (
<div className="w-32">
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.type}
</Badge>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.status === "Done" ? (
<IconCircleCheckFilled className="fill-green-500 dark:fill-green-400" />
) : (
<IconLoader />
)}
{row.original.status}
</Badge>
),
},
{
accessorKey: "target",
header: () => <div className="w-full text-right">Target</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-target`} className="sr-only">
Target
</Label>
<Input
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
defaultValue={row.original.target}
id={`${row.original.id}-target`}
/>
</form>
),
},
{
accessorKey: "limit",
header: () => <div className="w-full text-right">Limit</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-limit`} className="sr-only">
Limit
</Label>
<Input
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
defaultValue={row.original.limit}
id={`${row.original.id}-limit`}
/>
</form>
),
},
{
accessorKey: "reviewer",
header: "Reviewer",
cell: ({ row }) => {
const isAssigned = row.original.reviewer !== "Assign reviewer"
if (isAssigned) {
return row.original.reviewer
}
return (
<>
<Label htmlFor={`${row.original.id}-reviewer`} className="sr-only">
Reviewer
</Label>
<Select>
<SelectTrigger
className="w-38 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate"
size="sm"
id={`${row.original.id}-reviewer`}
>
<SelectValue placeholder="Assign reviewer" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
</SelectContent>
</Select>
</>
)
},
},
{
id: "actions",
cell: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Make a copy</DropdownMenuItem>
<DropdownMenuItem>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
})
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
data-dragging={isDragging}
ref={setNodeRef}
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
export function DataTable({
data: initialData,
}: {
data: z.infer<typeof schema>[]
}) {
const [data, setData] = React.useState(() => initialData)
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [sorting, setSorting] = React.useState<SortingState>([])
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
})
const sortableId = React.useId()
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
const dataIds = React.useMemo<UniqueIdentifier[]>(
() => data?.map(({ id }) => id) || [],
[data]
)
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
getRowId: (row) => row.id.toString(),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (active && over && active.id !== over.id) {
setData((data) => {
const oldIndex = dataIds.indexOf(active.id)
const newIndex = dataIds.indexOf(over.id)
return arrayMove(data, oldIndex, newIndex)
})
}
}
return (
<Tabs
defaultValue="outline"
className="w-full flex-col justify-start gap-6"
>
<div className="flex items-center justify-between px-4 lg:px-6">
<Label htmlFor="view-selector" className="sr-only">
View
</Label>
<Select defaultValue="outline">
<SelectTrigger
className="flex w-fit @4xl/main:hidden"
size="sm"
id="view-selector"
>
<SelectValue placeholder="Select a view" />
</SelectTrigger>
<SelectContent>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="past-performance">Past Performance</SelectItem>
<SelectItem value="key-personnel">Key Personnel</SelectItem>
<SelectItem value="focus-documents">Focus Documents</SelectItem>
</SelectContent>
</Select>
<TabsList className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
<TabsTrigger value="outline">Outline</TabsTrigger>
<TabsTrigger value="past-performance">
Past Performance <Badge variant="secondary">3</Badge>
</TabsTrigger>
<TabsTrigger value="key-personnel">
Key Personnel <Badge variant="secondary">2</Badge>
</TabsTrigger>
<TabsTrigger value="focus-documents">Focus Documents</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Customize Columns</span>
<span className="lg:hidden">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm">
<IconPlus />
<span className="hidden lg:inline">Add Section</span>
</Button>
</div>
</div>
<TabsContent
value="outline"
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
>
<div className="overflow-hidden rounded-lg border">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="bg-muted sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{table.getRowModel().rows?.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
<div className="flex items-center justify-between px-4">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</TabsContent>
<TabsContent
value="past-performance"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent
value="focus-documents"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
</Tabs>
)
}
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 },
]
const chartConfig = {
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
const isMobile = useIsMobile()
return (
<Drawer direction={isMobile ? "bottom" : "right"}>
<DrawerTrigger asChild>
<Button variant="link" className="text-foreground w-fit px-0 text-left">
{item.header}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="gap-1">
<DrawerTitle>{item.header}</DrawerTitle>
<DrawerDescription>
Showing total visitors for the last 6 months
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
{!isMobile && (
<>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={chartData}
margin={{
left: 0,
right: 10,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
hide
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="mobile"
type="natural"
fill="var(--color-mobile)"
fillOpacity={0.6}
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="var(--color-desktop)"
fillOpacity={0.4}
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
<Separator />
<div className="grid gap-2">
<div className="flex gap-2 leading-none font-medium">
Trending up by 5.2% this month{" "}
<IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Showing total visitors for the last 6 months. This is just
some random text to test the layout. It spans multiple lines
and should wrap around.
</div>
</div>
<Separator />
</>
)}
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="header">Header</Label>
<Input id="header" defaultValue={item.header} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="type">Type</Label>
<Select defaultValue={item.type}>
<SelectTrigger id="type" className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Table of Contents">
Table of Contents
</SelectItem>
<SelectItem value="Executive Summary">
Executive Summary
</SelectItem>
<SelectItem value="Technical Approach">
Technical Approach
</SelectItem>
<SelectItem value="Design">Design</SelectItem>
<SelectItem value="Capabilities">Capabilities</SelectItem>
<SelectItem value="Focus Documents">
Focus Documents
</SelectItem>
<SelectItem value="Narrative">Narrative</SelectItem>
<SelectItem value="Cover Page">Cover Page</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="status">Status</Label>
<Select defaultValue={item.status}>
<SelectTrigger id="status" className="w-full">
<SelectValue placeholder="Select a status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Done">Done</SelectItem>
<SelectItem value="In Progress">In Progress</SelectItem>
<SelectItem value="Not Started">Not Started</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="target">Target</Label>
<Input id="target" defaultValue={item.target} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="limit">Limit</Label>
<Input id="limit" defaultValue={item.limit} />
</div>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="reviewer">Reviewer</Label>
<Select defaultValue={item.reviewer}>
<SelectTrigger id="reviewer" className="w-full">
<SelectValue placeholder="Select a reviewer" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
<SelectItem value="Emily Whalen">Emily Whalen</SelectItem>
</SelectContent>
</Select>
</div>
</form>
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell } from 'recharts'
// 벤치마크 데이터
const benchmarkData = [
{ category: '유전체 점수', myFarm: 79.8, regional: 65.2, top10: 88.5 },
{ category: 'MPT 충족률', myFarm: 74.6, regional: 72.0, top10: 85.2 },
{ category: 'A등급 비율', myFarm: 37.5, regional: 18.8, top10: 45.0 },
{ category: '번식능력', myFarm: 72, regional: 70, top10: 82 },
]
export function RegionBenchmark() {
return (
<Card className="shadow-sm">
<CardHeader className="pb-2 md:pb-3">
<CardTitle className="text-xs md:text-sm font-semibold"> </CardTitle>
<CardDescription className="text-[11px] md:text-xs mt-0.5">
vs vs 10%
</CardDescription>
</CardHeader>
<CardContent className="pt-0 pb-3 md:pb-4">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={benchmarkData} barGap={4}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="category"
tick={{ fontSize: 11 }}
stroke="#6b7280"
/>
<YAxis
tick={{ fontSize: 11 }}
stroke="#6b7280"
domain={[0, 100]}
/>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '11px'
}}
formatter={(value: number) => `${value.toFixed(1)}`}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Bar dataKey="regional" fill="#9ca3af" name="보은군 평균" radius={[4, 4, 0, 0]} />
<Bar dataKey="myFarm" fill="#2563eb" name="내 농장" radius={[4, 4, 0, 0]} />
<Bar dataKey="top10" fill="#10b981" name="상위 10%" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,291 @@
"use client"
import * as React from "react"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { useIsMobile } from "@/hooks/use-mobile"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
export const description = "An interactive area chart"
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
{ date: "2024-05-01", desktop: 165, mobile: 220 },
{ date: "2024-05-02", desktop: 293, mobile: 310 },
{ date: "2024-05-03", desktop: 247, mobile: 190 },
{ date: "2024-05-04", desktop: 385, mobile: 420 },
{ date: "2024-05-05", desktop: 481, mobile: 390 },
{ date: "2024-05-06", desktop: 498, mobile: 520 },
{ date: "2024-05-07", desktop: 388, mobile: 300 },
{ date: "2024-05-08", desktop: 149, mobile: 210 },
{ date: "2024-05-09", desktop: 227, mobile: 180 },
{ date: "2024-05-10", desktop: 293, mobile: 330 },
{ date: "2024-05-11", desktop: 335, mobile: 270 },
{ date: "2024-05-12", desktop: 197, mobile: 240 },
{ date: "2024-05-13", desktop: 197, mobile: 160 },
{ date: "2024-05-14", desktop: 448, mobile: 490 },
{ date: "2024-05-15", desktop: 473, mobile: 380 },
{ date: "2024-05-16", desktop: 338, mobile: 400 },
{ date: "2024-05-17", desktop: 499, mobile: 420 },
{ date: "2024-05-18", desktop: 315, mobile: 350 },
{ date: "2024-05-19", desktop: 235, mobile: 180 },
{ date: "2024-05-20", desktop: 177, mobile: 230 },
{ date: "2024-05-21", desktop: 82, mobile: 140 },
{ date: "2024-05-22", desktop: 81, mobile: 120 },
{ date: "2024-05-23", desktop: 252, mobile: 290 },
{ date: "2024-05-24", desktop: 294, mobile: 220 },
{ date: "2024-05-25", desktop: 201, mobile: 250 },
{ date: "2024-05-26", desktop: 213, mobile: 170 },
{ date: "2024-05-27", desktop: 420, mobile: 460 },
{ date: "2024-05-28", desktop: 233, mobile: 190 },
{ date: "2024-05-29", desktop: 78, mobile: 130 },
{ date: "2024-05-30", desktop: 340, mobile: 280 },
{ date: "2024-05-31", desktop: 178, mobile: 230 },
{ date: "2024-06-01", desktop: 178, mobile: 200 },
{ date: "2024-06-02", desktop: 470, mobile: 410 },
{ date: "2024-06-03", desktop: 103, mobile: 160 },
{ date: "2024-06-04", desktop: 439, mobile: 380 },
{ date: "2024-06-05", desktop: 88, mobile: 140 },
{ date: "2024-06-06", desktop: 294, mobile: 250 },
{ date: "2024-06-07", desktop: 323, mobile: 370 },
{ date: "2024-06-08", desktop: 385, mobile: 320 },
{ date: "2024-06-09", desktop: 438, mobile: 480 },
{ date: "2024-06-10", desktop: 155, mobile: 200 },
{ date: "2024-06-11", desktop: 92, mobile: 150 },
{ date: "2024-06-12", desktop: 492, mobile: 420 },
{ date: "2024-06-13", desktop: 81, mobile: 130 },
{ date: "2024-06-14", desktop: 426, mobile: 380 },
{ date: "2024-06-15", desktop: 307, mobile: 350 },
{ date: "2024-06-16", desktop: 371, mobile: 310 },
{ date: "2024-06-17", desktop: 475, mobile: 520 },
{ date: "2024-06-18", desktop: 107, mobile: 170 },
{ date: "2024-06-19", desktop: 341, mobile: 290 },
{ date: "2024-06-20", desktop: 408, mobile: 450 },
{ date: "2024-06-21", desktop: 169, mobile: 210 },
{ date: "2024-06-22", desktop: 317, mobile: 270 },
{ date: "2024-06-23", desktop: 480, mobile: 530 },
{ date: "2024-06-24", desktop: 132, mobile: 180 },
{ date: "2024-06-25", desktop: 141, mobile: 190 },
{ date: "2024-06-26", desktop: 434, mobile: 380 },
{ date: "2024-06-27", desktop: 448, mobile: 490 },
{ date: "2024-06-28", desktop: 149, mobile: 200 },
{ date: "2024-06-29", desktop: 103, mobile: 160 },
{ date: "2024-06-30", desktop: 446, mobile: 400 },
]
const chartConfig = {
visitors: {
label: "Visitors",
},
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
export function ChartAreaInteractive() {
const isMobile = useIsMobile()
const [timeRange, setTimeRange] = React.useState("90d")
React.useEffect(() => {
if (isMobile) {
setTimeRange("7d")
}
}, [isMobile])
const filteredData = chartData.filter((item) => {
const date = new Date(item.date)
const referenceDate = new Date("2024-06-30")
let daysToSubtract = 90
if (timeRange === "30d") {
daysToSubtract = 30
} else if (timeRange === "7d") {
daysToSubtract = 7
}
const startDate = new Date(referenceDate)
startDate.setDate(startDate.getDate() - daysToSubtract)
return date >= startDate
})
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>Total Visitors</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Total for the last 3 months
</span>
<span className="@[540px]/card:hidden">Last 3 months</span>
</CardDescription>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
>
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Select a value"
>
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent className="rounded-lg">
<SelectItem value="90d" className="rounded-lg">
Last 3 months
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Last 7 days
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={filteredData}>
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={1.0}
/>
<stop
offset="95%"
stopColor="var(--color-desktop)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-mobile)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-mobile)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
indicator="dot"
/>
}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,230 @@
'use client'
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { TrendingUp, TrendingDown, Minus, Users, Heart, Activity } from "lucide-react"
import { IconTrendingUp, IconTrendingDown, IconMoodNeutral } from "@tabler/icons-react"
import { useEffect, useState } from "react"
import { dashboardApi, breedApi, cowApi } from "@/lib/api"
import { FarmSummaryDto, FarmEvaluationDto } from "@/types/dashboard.types"
import { useAuthStore } from "@/store/auth-store"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
interface KPIData {
label: string
value: string | number
change: number
changeLabel: string
icon: React.ReactNode
color: string
badge?: React.ReactNode
subtext?: string
}
interface KPIDashboardProps {
farmNo: number
}
export function KPIDashboard({ farmNo }: KPIDashboardProps) {
const { user } = useAuthStore()
const { filters } = useGlobalFilter()
const [kpiData, setKpiData] = useState<KPIData[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
// farmNo가 없으면 데이터 로드하지 않음
if (!farmNo) {
console.warn('farmNo가 없습니다.')
setLoading(false)
return
}
setLoading(true)
try {
// 필터를 백엔드 DTO 형식으로 변환
const filterDto = {
targetGenes: filters.selectedGenes?.length > 0 ? filters.selectedGenes : undefined,
// 추후 필요시 다른 필터 추가 가능
}
// 실제 API 데이터 가져오기
const [cows, evaluationData, breedSaves] = await Promise.all([
cowApi.findByFarmNo(farmNo).catch(() => []),
dashboardApi.getFarmEvaluation(farmNo, filterDto).catch(() => null),
user
? breedApi.findByUser(user.pkUserNo).catch(() => [])
: Promise.resolve([]),
])
// 안전하게 데이터 추출
const totalCows = Array.isArray(cows) ? cows.length : 0
const analysisComplete = Array.isArray(cows)
? cows.filter((cow: any) => cow.genomeScore !== null && cow.genomeScore !== undefined).length
: 0
const avgGenomeScore = evaluationData?.genomeScore ?? 0
const breedSaveCount = Array.isArray(breedSaves) ? breedSaves.length : 0
// KPI 데이터 구성
setKpiData([
{
label: "전체 개체 수",
value: totalCows,
change: 0,
changeLabel: `분석 완료: ${analysisComplete}마리`,
icon: <Users className="h-7 w-7" />,
color: "blue",
subtext: `분석 완료 ${analysisComplete}마리`
},
{
label: "유전체 평균",
value: avgGenomeScore.toFixed(1),
change: avgGenomeScore >= 70 ? 4.8 : -1.5,
changeLabel: avgGenomeScore >= 70 ? '육질/육량 형질 우수' : '형질 개선 필요',
icon: <Activity className="h-7 w-7" />,
color: "cyan",
badge: avgGenomeScore >= 70 ? (
<Badge className="badge-gene-positive flex items-center gap-1">
<IconTrendingUp className="w-3 h-3" />
</Badge>
) : avgGenomeScore >= 50 ? (
<Badge className="badge-gene-neutral flex items-center gap-1">
<IconMoodNeutral className="w-3 h-3" />
</Badge>
) : (
<Badge className="badge-gene-negative flex items-center gap-1">
<IconTrendingDown className="w-3 h-3" />
</Badge>
)
},
{
label: "교배계획 저장",
value: breedSaveCount,
change: 0,
changeLabel: '저장된 교배 조합',
icon: <Heart className="h-7 w-7" />,
color: "pink"
}
])
} catch (error) {
console.error('KPI 데이터 로드 실패:', error)
console.error('에러 상세:', error instanceof Error ? error.message : '알 수 없는 에러')
// 에러 시 기본값
setKpiData([
{
label: "전체 개체 수",
value: 0,
change: 0,
changeLabel: "데이터 없음",
icon: <Users className="h-7 w-7" />,
color: "blue"
},
{
label: "유전체 평균",
value: "0.0",
change: 0,
changeLabel: "데이터 없음",
icon: <Activity className="h-7 w-7" />,
color: "cyan"
},
{
label: "교배계획 저장",
value: 0,
change: 0,
changeLabel: "데이터 없음",
icon: <Heart className="h-7 w-7" />,
color: "pink"
}
])
} finally {
setLoading(false)
}
}
fetchData()
}, [farmNo, user, filters.selectedGenes])
if (loading) {
return (
<>
{[1, 2, 3].map((i) => (
<Card key={i} className="bg-slate-50/50 border-0">
<CardContent className="p-4 md:p-5">
<div className="animate-pulse space-y-3">
<div className="flex items-start justify-between">
<div className="h-4 w-24 bg-gray-200 rounded" />
<div className="h-5 w-12 bg-gray-200 rounded-full" />
</div>
<div className="space-y-2">
<div className="h-10 w-16 bg-gray-300 rounded" />
<div className="h-3 w-32 bg-gray-200 rounded" />
</div>
</div>
</CardContent>
</Card>
))}
</>
)
}
const getColorClasses = (color: string) => {
const colors = {
blue: "bg-blue-50 text-blue-700 border-blue-200",
cyan: "bg-cyan-50 text-cyan-700 border-cyan-200",
pink: "bg-pink-50 text-pink-700 border-pink-200",
orange: "bg-orange-50 text-orange-700 border-orange-200"
}
return colors[color as keyof typeof colors] || colors.blue
}
const getTrendIcon = (change: number) => {
if (change > 0) return <TrendingUp className="h-4 w-4" />
if (change < 0) return <TrendingDown className="h-4 w-4" />
return <Minus className="h-4 w-4" />
}
const getTrendColor = (change: number) => {
if (change > 0) return "text-green-600 bg-green-50"
if (change < 0) return "text-red-600 bg-red-50"
return "text-gray-600 bg-gray-50"
}
return (
<>
{kpiData.map((kpi, index) => (
<Card key={index} className="bg-slate-50/50 border-0 hover:bg-slate-100/50 transition-colors">
<CardContent className="p-4 md:p-5">
<div className="flex items-start justify-between mb-3">
<p className="text-sm md:text-sm text-gray-600 font-medium">{kpi.label}</p>
{kpi.badge ? (
<div>{kpi.badge}</div>
) : kpi.change !== 0 ? (
<div className={`flex items-center gap-1 text-xs font-medium ${
kpi.change > 0 ? 'text-green-600' :
kpi.change < 0 ? 'text-red-600' :
'text-gray-600'
}`}>
{getTrendIcon(kpi.change)}
<span>{Math.abs(kpi.change)}%</span>
</div>
) : null}
</div>
<div className="space-y-2">
<div className="flex items-baseline gap-2">
<p className="text-3xl md:text-4xl font-bold tracking-tight">
{kpi.value}
</p>
{index === 0 && <span className="text-sm text-gray-500"></span>}
</div>
{kpi.changeLabel && (
<p className="text-xs md:text-sm text-gray-500 line-clamp-1">{kpi.changeLabel}</p>
)}
</div>
</CardContent>
</Card>
))}
</>
)
}

View File

@@ -0,0 +1,169 @@
'use client'
import { IconTrendingDown, IconTrendingUp, IconMoodNeutral } from "@tabler/icons-react"
import { useEffect, useState } from "react"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
CardContent,
} from "@/components/ui/card"
import { dashboardApi } from "@/lib/api"
import { FarmSummaryDto, FarmEvaluationDto } from "@/types/dashboard.types"
interface SectionCardsProps {
farmNo: number
}
export function SectionCards({ farmNo }: SectionCardsProps) {
const [summary, setSummary] = useState<FarmSummaryDto | null>(null)
const [evaluation, setEvaluation] = useState<FarmEvaluationDto | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
if (!farmNo) {
console.warn('farmNo가 없습니다.')
setLoading(false)
return
}
try {
// 백엔드 API에서 실제 데이터 조회
const [summaryData, evaluationData] = await Promise.all([
dashboardApi.getFarmSummary(farmNo).catch(err => {
console.error('농장 요약 조회 실패:', err)
return null
}),
dashboardApi.getFarmEvaluation(farmNo).catch(err => {
console.error('농장 평가 조회 실패:', err)
return null
}),
])
setSummary(summaryData)
setEvaluation(evaluationData)
} catch (error) {
console.error('대시보드 데이터 로드 실패:', error)
// API 실패 시 기본값 설정
setSummary({
totalCows: 0,
analysisComplete: 0,
})
setEvaluation(null)
} finally {
setLoading(false)
}
}
fetchData()
}, [farmNo])
if (loading) {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i} className="@container/card animate-pulse">
<CardHeader>
<CardDescription> ...</CardDescription>
<CardTitle className="text-2xl font-semibold">--</CardTitle>
</CardHeader>
</Card>
))}
</div>
)
}
const totalCows = summary?.totalCows || 0
const analysisComplete = summary?.analysisComplete || 0
// 유전자 보유율 = (육질형 + 육량형) / 2
const avgGeneScore = evaluation?.genePossession
? (evaluation.genePossession.meatQuality.averageRate + evaluation.genePossession.meatQuantity.averageRate) / 2
: 0
const avgGenomeScore = evaluation?.genomeScore || 0
return (
<div className="grid grid-cols-1 gap-5 px-4 lg:px-6 sm:grid-cols-2 xl:grid-cols-4">
<Card className="shadow-sm hover:shadow-md transition-all duration-200 border-l-4 border-l-blue-500">
<CardHeader className="pb-3">
<CardDescription className="text-xs font-semibold uppercase tracking-wide"> </CardDescription>
<CardTitle className="text-3xl font-bold text-foreground mt-2">{totalCows}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm">
<div className="h-2 w-2 rounded-full bg-green-500"></div>
<p className="text-muted-foreground">
<span className="font-semibold text-foreground">{analysisComplete}</span>
</p>
</div>
</CardContent>
</Card>
<Card className="shadow-sm hover:shadow-md transition-all duration-200 border-l-4 border-l-amber-500">
<CardHeader className="pb-3">
<CardDescription className="text-xs font-semibold uppercase tracking-wide"> </CardDescription>
<CardTitle className="text-3xl font-bold text-foreground mt-2">{avgGeneScore.toFixed(1)}%</CardTitle>
</CardHeader>
<CardContent>
<Badge className={`${avgGeneScore >= 70 ? 'badge-gene-positive' : avgGeneScore >= 50 ? 'badge-gene-neutral' : 'badge-gene-negative'} flex items-center gap-1 w-fit`}>
{avgGeneScore >= 70 ? <IconTrendingUp className="w-3 h-3" /> : avgGeneScore >= 50 ? <IconMoodNeutral className="w-3 h-3" /> : <IconTrendingDown className="w-3 h-3" />}
{avgGeneScore >= 70 ? '우수' : avgGeneScore >= 50 ? '보통' : '개선필요'}
</Badge>
<p className="text-xs text-muted-foreground mt-2">
{avgGeneScore >= 70 ? '우량 유전자 보유율 높음' : '유전자 개선 필요'}
</p>
</CardContent>
</Card>
<Card className="shadow-sm hover:shadow-md transition-all duration-200 border-l-4 border-l-cyan-500">
<CardHeader className="pb-3">
<CardDescription className="text-xs font-semibold uppercase tracking-wide"> </CardDescription>
<CardTitle className="text-3xl font-bold text-foreground mt-2">{avgGenomeScore.toFixed(1)}</CardTitle>
</CardHeader>
<CardContent>
<Badge className={`${avgGenomeScore >= 70 ? 'badge-gene-positive' : avgGenomeScore >= 50 ? 'badge-gene-neutral' : 'badge-gene-negative'} flex items-center gap-1 w-fit`}>
{avgGenomeScore >= 70 ? <IconTrendingUp className="w-3 h-3" /> : avgGenomeScore >= 50 ? <IconMoodNeutral className="w-3 h-3" /> : <IconTrendingDown className="w-3 h-3" />}
{avgGenomeScore >= 70 ? '우수' : avgGenomeScore >= 50 ? '보통' : '개선필요'}
</Badge>
<p className="text-xs text-muted-foreground mt-2">
{avgGenomeScore >= 70 ? '육질/육량 형질 우수' : '형질 개선 필요'}
</p>
</CardContent>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription> </CardDescription>
<CardTitle className={`text-2xl font-semibold ${
(evaluation?.genomeScore || 0) >= 70 ? 'text-green-600' :
(evaluation?.genomeScore || 0) >= 60 ? 'text-blue-600' :
(evaluation?.genomeScore || 0) >= 40 ? 'text-yellow-600' :
'text-red-600'
}`}>
{evaluation?.genomeScore?.toFixed(1) || '-'}
</CardTitle>
</CardHeader>
<CardContent>
<Badge className={
(evaluation?.genomeScore || 0) >= 70 ? 'bg-green-100 text-green-800' :
(evaluation?.genomeScore || 0) >= 60 ? 'bg-blue-100 text-blue-800' :
(evaluation?.genomeScore || 0) >= 40 ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'
}>
{(evaluation?.genomeScore || 0) >= 60 ? <IconTrendingUp className="w-4 h-4" /> : <IconMoodNeutral className="w-4 h-4" />}
{evaluation?.genomeRank || '-'}
</Badge>
<p className="text-xs text-muted-foreground mt-2">
( )
</p>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,414 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useEffect, useState } from "react"
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
import { Trophy, AlertTriangle, Sparkles, Star, AlertCircle, Lightbulb } from "lucide-react"
import { dashboardApi } from "@/lib/api/dashboard.api"
import { toast } from "sonner"
interface CattleItem {
rank: number
cattleId: string
name: string
score: number
reason: string
scoreUnit?: string // 점수 단위 (점, 마리 등)
}
interface Top3ListsProps {
farmNo: number | null
mode?: 'full' | 'compact' | 'cull-only' | 'recommend-only'
}
export function Top3Lists({ farmNo, mode = 'full' }: Top3ListsProps) {
const { selectedYear } = useAnalysisYear()
const { filters } = useGlobalFilter()
const [excellentList, setExcellentList] = useState<CattleItem[]>([])
const [cullingList, setCullingList] = useState<CattleItem[]>([])
const [recommendList, setRecommendList] = useState<CattleItem[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
if (!farmNo) {
setLoading(false)
return
}
setLoading(true)
try {
// 필터 조건 생성
const filterDto = {
targetGenes: filters.selectedGenes,
limit: 3, // Top 3만 가져오기
}
// 병렬로 API 호출
const [excellentData, cullData, kpnData] = await Promise.all([
dashboardApi.getExcellentCows(farmNo, filterDto),
dashboardApi.getCullCows(farmNo, filterDto),
dashboardApi.getKpnRecommendationAggregation(farmNo, filterDto),
])
// 우수개체 데이터 변환
const excellentList: CattleItem[] = (excellentData || []).map((item: any, index: number) => ({
rank: index + 1,
cattleId: item.cowNo || '없음',
name: item.cowName || item.cowNo || '정보 없음', // 이름이 없으면 개체번호 사용
score: Math.round(item.overallScore || 0),
scoreUnit: '점',
reason: item.reason || '정보 없음',
}))
// 도태개체 데이터 변환
const cullList: CattleItem[] = (cullData || []).map((item: any, index: number) => ({
rank: index + 1,
cattleId: item.cowNo || '없음',
name: item.cowName || item.cowNo || '정보 없음', // 이름이 없으면 개체번호 사용
score: Math.round(item.overallScore || 0),
scoreUnit: '점',
reason: item.reason || '정보 없음',
}))
// KPN 추천 데이터 변환 (상위 3개)
const recommendList: CattleItem[] = ((kpnData?.kpnAggregations || []) as any[])
.slice(0, 3)
.map((item: any, index: number) => ({
rank: index + 1,
cattleId: item.kpnNumber || '없음',
name: item.kpnName || '이름 없음',
score: item.recommendedCowCount || 0,
scoreUnit: '마리',
reason: `평균 매칭점수 ${Math.round(item.avgMatchingScore || 0)}`,
}))
setExcellentList(excellentList)
setCullingList(cullList)
setRecommendList(recommendList)
} catch (error: any) {
toast.error(`데이터를 불러오는데 실패했습니다: ${error?.message || '알 수 없는 오류'}`)
// 에러 시 빈 배열 설정
setExcellentList([])
setCullingList([])
setRecommendList([])
} finally {
setLoading(false)
}
}
fetchData()
}, [selectedYear, filters, farmNo])
if (loading) {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 md:gap-4">
{[1, 2, 3].map((i) => (
<Card key={i} className="border-gray-200">
<CardHeader className="pb-2 md:pb-3">
<CardTitle className="text-xs md:text-sm font-semibold"> ...</CardTitle>
</CardHeader>
<CardContent className="pt-0 pb-2 md:pb-3">
<div className="h-[150px] flex items-center justify-center">
<div className="animate-pulse space-y-2 w-full">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)
}
if (!farmNo) {
return (
<div className="px-4 lg:px-6">
<h2 className="text-xl font-bold mb-4"> </h2>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<CardTitle>
{i === 1 ? '우수개체 Top3' : i === 2 ? '도태대상 Top3' : 'KPN추천 Top3'}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px] flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground mb-2"> </p>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
const renderListCard = (
title: string,
description: string,
icon: React.ReactNode,
items: CattleItem[],
variant: 'excellent' | 'culling' | 'recommend'
) => {
const colorSchemes = {
excellent: {
badge: 'bg-green-600 text-white'
},
culling: {
badge: 'bg-red-600 text-white'
},
recommend: {
badge: 'bg-blue-600 text-white'
}
}
const scheme = colorSchemes[variant]
return (
<Card>
<CardContent className="pt-4">
<div className="space-y-2.5">
{items.map((item, index) => (
<div
key={item.cattleId}
className="relative p-3 rounded-lg border border-gray-200 bg-white hover:shadow-sm transition-shadow"
>
{/* 순위 배지 */}
<div className="absolute -top-1.5 -right-1.5">
<div className={`w-6 h-6 rounded-full ${scheme.badge} flex items-center justify-center text-[10px] font-bold shadow-sm`}>
{item.rank}
</div>
</div>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<p className="font-semibold text-sm text-foreground mb-0.5">{item.name}</p>
<p className="text-[10px] text-muted-foreground font-mono">{item.cattleId}</p>
</div>
<div className="text-right ml-2">
<p className="text-xl font-bold text-foreground">{item.score}</p>
<p className="text-[10px] text-muted-foreground">{item.scoreUnit || '점'}</p>
</div>
</div>
<div className="pt-2 border-t border-gray-100">
<p className="text-xs text-muted-foreground">{item.reason}</p>
</div>
</div>
))}
</div>
{/* 요약 */}
<div className="mt-4 pt-3 border-t border-border">
<div className="text-center space-y-0.5">
<p className="text-xs font-medium text-foreground">
{variant === 'excellent'
? '농장 내 최상위 개체'
: variant === 'culling'
? '개선 또는 도태 권장'
: '최적 교배 추천'}
</p>
<p className="text-[10px] text-muted-foreground">
{filters.analysisIndex === 'GENE' ? '유전자 기반 분석' : '유전능력 기반 분석'}
</p>
</div>
</div>
</CardContent>
</Card>
)
}
// compact 모드: 우수개체만 표시
if (mode === 'compact') {
return renderListCard(
'우수개체 Top3',
'농장 내 상위 개체',
<Trophy className="h-5 w-5 text-green-600" />,
excellentList,
'excellent'
)
}
// cull-only 모드: 도태대상만 표시
if (mode === 'cull-only') {
return renderListCard(
'도태대상 Top3',
'개선 필요 개체',
<AlertTriangle className="h-5 w-5 text-red-600" />,
cullingList,
'culling'
)
}
// recommend-only 모드: KPN추천만 표시
if (mode === 'recommend-only') {
return renderListCard(
'KPN추천 Top3',
'최적 씨수소 추천',
<Sparkles className="h-5 w-5 text-blue-600" />,
recommendList,
'recommend'
)
}
// Vercel 스타일 리스트 아이템 렌더링
const renderListItem = (item: CattleItem, variant: 'excellent' | 'culling' | 'recommend') => {
const colorSchemes = {
excellent: {
icon: <Trophy className="h-4 w-4 text-green-600" />,
scoreColor: 'text-green-600',
dotColor: 'bg-green-500'
},
culling: {
icon: <AlertTriangle className="h-4 w-4 text-red-600" />,
scoreColor: 'text-red-600',
dotColor: 'bg-red-500'
},
recommend: {
icon: <Sparkles className="h-4 w-4 text-blue-600" />,
scoreColor: 'text-blue-600',
dotColor: 'bg-blue-500'
}
}
const scheme = colorSchemes[variant]
return (
<div className="flex items-center justify-between py-2 md:py-2.5 border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors px-1 md:px-1.5 -mx-1 md:-mx-1.5 rounded">
<div className="flex items-center gap-2 md:gap-2.5 flex-1 min-w-0">
<div className={`w-1 h-1 md:w-1.5 md:h-1.5 rounded-full ${scheme.dotColor} flex-shrink-0`}></div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 md:gap-1.5">
<p className="text-xs md:text-sm font-medium text-foreground truncate">{item.name}</p>
<span className="text-[9px] md:text-[10px] text-gray-400 font-mono flex-shrink-0">{item.cattleId}</span>
</div>
<p className="text-[9px] md:text-[10px] text-gray-500 mt-0.5 line-clamp-1">{item.reason}</p>
</div>
</div>
<div className="flex items-center gap-1 md:gap-1.5 flex-shrink-0 ml-2">
<p className={`text-sm md:text-base font-semibold ${scheme.scoreColor}`}>{item.score}</p>
<span className="text-[9px] md:text-[10px] text-gray-400">{item.scoreUnit || '점'}</span>
</div>
</div>
)
}
// full 모드: Vercel 스타일 리스트로 표시
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 md:gap-4">
{/* 우수개체 섹션 */}
<Card className="border-gray-200 shadow-none hover:border-black hover:shadow-lg transition-all duration-200">
<CardHeader className="pb-2 md:pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 md:gap-2">
<Trophy className="h-3 w-3 md:h-3.5 md:w-3.5 text-green-600" />
<CardTitle className="text-xs md:text-sm font-semibold"> Top3</CardTitle>
</div>
<svg className="w-3 h-3 md:w-3.5 md:h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</CardHeader>
<CardContent className="pt-0 pb-2 md:pb-3">
{excellentList.length > 0 ? (
<div className="space-y-0">
{excellentList.map((item) => (
<div key={item.cattleId}>
{renderListItem(item, 'excellent')}
</div>
))}
</div>
) : (
<div className="h-[150px] flex items-center justify-center">
<div className="text-center">
<Trophy className="h-8 w-8 text-gray-300 mx-auto mb-2" />
<p className="text-xs text-muted-foreground"> </p>
<p className="text-[10px] text-gray-400 mt-1"> </p>
</div>
</div>
)}
</CardContent>
</Card>
{/* 도태대상 섹션 */}
<Card className="border-gray-200 shadow-none hover:border-black hover:shadow-lg transition-all duration-200">
<CardHeader className="pb-2 md:pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 md:gap-2">
<AlertTriangle className="h-3 w-3 md:h-3.5 md:w-3.5 text-red-600" />
<CardTitle className="text-xs md:text-sm font-semibold"> Top3</CardTitle>
</div>
<svg className="w-3 h-3 md:w-3.5 md:h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</CardHeader>
<CardContent className="pt-0 pb-2 md:pb-3">
{cullingList.length > 0 ? (
<div className="space-y-0">
{cullingList.map((item) => (
<div key={item.cattleId}>
{renderListItem(item, 'culling')}
</div>
))}
</div>
) : (
<div className="h-[150px] flex items-center justify-center">
<div className="text-center">
<AlertTriangle className="h-8 w-8 text-gray-300 mx-auto mb-2" />
<p className="text-xs text-muted-foreground"> </p>
<p className="text-[10px] text-gray-400 mt-1"> </p>
</div>
</div>
)}
</CardContent>
</Card>
{/* KPN 추천 섹션 */}
<Card className="border-gray-200 shadow-none hover:border-black hover:shadow-lg transition-all duration-200">
<CardHeader className="pb-2 md:pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 md:gap-2">
<Sparkles className="h-3 w-3 md:h-3.5 md:w-3.5 text-blue-600" />
<CardTitle className="text-xs md:text-sm font-semibold">KPN Top3</CardTitle>
</div>
<svg className="w-3 h-3 md:w-3.5 md:h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</CardHeader>
<CardContent className="pt-0 pb-2 md:pb-3">
{recommendList.length > 0 ? (
<div className="space-y-0">
{recommendList.map((item) => (
<div key={item.cattleId}>
{renderListItem(item, 'recommend')}
</div>
))}
</div>
) : (
<div className="h-[150px] flex items-center justify-center">
<div className="text-center">
<Sparkles className="h-8 w-8 text-gray-300 mx-auto mb-2" />
<p className="text-xs text-muted-foreground"> </p>
<p className="text-[10px] text-gray-400 mt-1">KPN </p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,235 @@
'use client'
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { User, CheckCircle2, BarChart3 } from "lucide-react"
import { CowDetail } from "@/types/cow.types"
import { GenomeTrait as GenomeTraitType } from "@/types/genome.types"
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Legend, Tooltip as RechartsTooltip } from 'recharts'
interface CowCompareModalProps {
isOpen: boolean
onClose: () => void
compareCowsData: { cow: CowDetail; genome: GenomeTraitType[] }[]
transformGenomeData: (genomeData: GenomeTraitType[]) => any[]
CATEGORIES: string[]
TRAIT_COLORS: string[]
}
export function CowCompareModal({
isOpen,
onClose,
compareCowsData,
transformGenomeData,
CATEGORIES,
TRAIT_COLORS
}: CowCompareModalProps) {
if (!isOpen || compareCowsData.length === 0) return null
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white w-full max-w-6xl max-h-[90vh] rounded-lg overflow-hidden flex flex-col">
{/* 헤더 */}
<div className="p-4 border-b border-border flex items-center justify-between bg-primary/5">
<div>
<h2 className="text-lg font-bold flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-primary" />
</h2>
<p className="text-sm text-muted-foreground mt-1">
{compareCowsData.length}
</p>
</div>
<Button onClick={onClose} variant="ghost" size="sm">
</Button>
</div>
{/* 비교 내용 */}
<div className="flex-1 overflow-y-auto p-4">
{/* 개체 카드들 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{compareCowsData.map((cowData, idx) => {
const genomeTraits = transformGenomeData(cowData.genome)
const avgBreedVal = genomeTraits.length > 0
? genomeTraits.reduce((sum: number, t: any) => sum + t.breedVal, 0) / genomeTraits.length
: 0
const avgPercentile = genomeTraits.length > 0
? genomeTraits.reduce((sum: number, t: any) => sum + t.percentile, 0) / genomeTraits.length
: 0
return (
<Card key={cowData.cow.pkCowNo} className={idx === 0 ? 'border-2 border-primary' : ''}>
<CardHeader className={idx === 0 ? 'bg-primary/5' : ''}>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{cowData.cow.cowId || cowData.cow.pkCowNo}</CardTitle>
{cowData.cow.cowId && (
<CardDescription>{cowData.cow.cowId}</CardDescription>
)}
</div>
{idx === 0 && (
<Badge className="bg-primary text-white"> </Badge>
)}
</div>
</CardHeader>
<CardContent className="pt-4">
<div className="space-y-3">
<div className="text-center p-3 bg-muted/30 rounded-lg">
<div className="text-xs text-muted-foreground mb-1"> </div>
<div className="text-2xl font-bold text-primary">
{avgBreedVal > 0 ? '+' : ''}{avgBreedVal.toFixed(2)}σ
</div>
<div className="text-xs text-muted-foreground mt-1">
{(100 - avgPercentile).toFixed(1)}%
</div>
</div>
<div className="text-xs space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-semibold">
{cowData.cow.cowBirthDt
? new Date(cowData.cow.cowBirthDt).toLocaleDateString('ko-KR')
: 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-semibold">
{cowData.cow.age ? `${cowData.cow.age}` : 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> </span>
<span className="font-semibold">{genomeTraits.length}</span>
</div>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
{/* 카테고리별 비교 차트 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<div className="h-96">
<ResponsiveContainer width="100%" height="100%">
<RadarChart>
<PolarGrid stroke="#e2e8f0" />
<PolarAngleAxis
dataKey="category"
tick={{ fill: '#64748b', fontSize: 12 }}
/>
<PolarRadiusAxis
angle={90}
domain={[-1, 2]}
tick={{ fill: '#64748b', fontSize: 10 }}
/>
<RechartsTooltip />
<Legend />
{compareCowsData.map((cowData, idx) => {
const genomeTraits = transformGenomeData(cowData.genome)
const categoryData = CATEGORIES.map(cat => {
const traits = genomeTraits.filter((t: any) => t.category === cat)
const avgBreedVal = traits.length > 0
? traits.reduce((sum: number, t: any) => sum + t.breedVal, 0) / traits.length
: 0
return {
category: cat,
value: avgBreedVal
}
})
const cowLabel = cowData.cow.cowId || String(cowData.cow.pkCowNo)
return (
<Radar
key={cowData.cow.pkCowNo}
name={idx === 0 ? `${cowLabel} (현재)` : cowLabel}
dataKey="value"
stroke={TRAIT_COLORS[idx % TRAIT_COLORS.length]}
fill={TRAIT_COLORS[idx % TRAIT_COLORS.length]}
fillOpacity={idx === 0 ? 0.6 : 0.3}
strokeWidth={idx === 0 ? 3 : 2}
/>
)
})}
</RadarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* 형질별 비교 테이블 */}
<Card className="mt-4">
<CardHeader>
<CardTitle> (Top 10)</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b-2">
<tr>
<th className="px-3 py-2 text-left font-semibold sticky left-0 bg-muted/50"></th>
{compareCowsData.map((cowData, idx) => {
const cowLabel = cowData.cow.cowId || String(cowData.cow.pkCowNo)
return (
<th key={cowData.cow.pkCowNo} className="px-3 py-2 text-center font-semibold">
{idx === 0 ? `${cowLabel}\n(현재)` : cowLabel}
</th>
)
})}
</tr>
</thead>
<tbody className="divide-y">
{transformGenomeData(compareCowsData[0].genome).slice(0, 10).map((trait: any) => (
<tr key={trait.id} className="hover:bg-muted/30">
<td className="px-3 py-2 font-medium sticky left-0 bg-white">
{trait.name}
</td>
{compareCowsData.map((cowData) => {
const genomeTraits = transformGenomeData(cowData.genome)
const matchTrait = genomeTraits.find((t: any) => t.name === trait.name)
return (
<td key={cowData.cow.pkCowNo} className="px-3 py-2 text-center">
{matchTrait ? (
<div>
<div className={`font-bold ${matchTrait.breedVal > 0 ? 'text-primary' : 'text-muted-foreground'}`}>
{matchTrait.breedVal > 0 ? '+' : ''}{matchTrait.breedVal.toFixed(2)}σ
</div>
<div className="text-xs text-muted-foreground">
{matchTrait.percentile.toFixed(1)}%
</div>
</div>
) : (
<span className="text-muted-foreground">N/A</span>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
{/* 푸터 */}
<div className="p-4 border-t border-border bg-muted/30 flex justify-end">
<Button onClick={onClose}>
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,159 @@
'use client'
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { User, CheckCircle2 } from "lucide-react"
import { CowDetail } from "@/types/cow.types"
interface CowSelectSheetProps {
isOpen: boolean
onClose: () => void
farmCows: CowDetail[]
selectedCowsForCompare: number[]
toggleCowForCompare: (cowNo: number) => void
onCompare: () => void
onClearSelection: () => void
}
export function CowSelectSheet({
isOpen,
onClose,
farmCows,
selectedCowsForCompare,
toggleCowForCompare,
onCompare,
onClearSelection
}: CowSelectSheetProps) {
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center md:justify-center">
<div className="bg-white w-full md:max-w-3xl md:max-h-[80vh] md:rounded-lg overflow-hidden flex flex-col max-h-[90vh] rounded-t-2xl md:rounded-2xl">
{/* 헤더 */}
<div className="p-4 border-b border-border flex items-center justify-between bg-primary/5">
<div>
<h2 className="text-lg font-bold flex items-center gap-2">
<User className="w-5 h-5 text-primary" />
</h2>
<p className="text-sm text-muted-foreground mt-1">
({selectedCowsForCompare.length}/{farmCows.length})
</p>
</div>
<Button onClick={onClose} variant="ghost" size="sm">
</Button>
</div>
{/* 개체 목록 */}
<div className="flex-1 overflow-y-auto p-4">
{farmCows.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<User className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p> </p>
</div>
) : (
<div className="space-y-2">
{farmCows.map((farmCow) => {
const isSelected = selectedCowsForCompare.includes(farmCow.pkCowNo)
return (
<div
key={farmCow.pkCowNo}
onClick={() => toggleCowForCompare(farmCow.pkCowNo)}
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50 hover:bg-muted/50'
}`}
>
<div className="flex items-start gap-3">
{/* 체크박스 */}
<div className="flex items-center pt-1">
<div
className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
isSelected
? 'bg-primary border-primary'
: 'border-muted-foreground'
}`}
>
{isSelected && (
<CheckCircle2 className="w-4 h-4 text-white" />
)}
</div>
</div>
{/* 개체 정보 */}
<div className="flex-1">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-bold text-foreground">{farmCow.cowId || farmCow.pkCowNo}</h4>
{farmCow.cowId && (
<p className="text-sm text-muted-foreground">{farmCow.cowId}</p>
)}
</div>
</div>
{/* 상세 정보 */}
<div className="grid grid-cols-3 gap-2 text-xs mt-2">
<div>
<div className="text-muted-foreground"></div>
<div className="font-semibold text-foreground">
{farmCow.cowBirthDt
? new Date(farmCow.cowBirthDt).toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
})
: 'N/A'}
</div>
</div>
<div>
<div className="text-muted-foreground"></div>
<div className="font-semibold text-foreground">
{farmCow.age ? `${farmCow.age}` : 'N/A'}
</div>
</div>
<div>
<div className="text-muted-foreground"></div>
<div className="font-semibold text-foreground">
{farmCow.cowSex === 'F' ? '암' : farmCow.cowSex === 'M' ? '수' : 'N/A'}
</div>
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
{/* 푸터 */}
<div className="p-4 border-t border-border bg-muted/30 flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{selectedCowsForCompare.length}
</div>
<div className="flex gap-2">
<Button
onClick={onClearSelection}
variant="ghost"
size="sm"
disabled={selectedCowsForCompare.length === 0}
>
</Button>
<Button
onClick={onCompare}
className="gap-2"
disabled={selectedCowsForCompare.length === 0}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,300 @@
'use client'
import { useState, useMemo, useEffect } from "react"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Search, Loader2 } from "lucide-react"
import { geneApi } from "@/lib/api/gene.api"
/**
* 마커 데이터 타입 (API에서 받아오는 형식)
*/
interface MarkerData {
pkMarkerNo: number
markerNm: string
markerDesc: string
markerTypeCd: string
relatedTrait: string
favorableAllele: string
useYn: string
markerTypeInfo?: {
pkTypeCd: string
typeNm: string
typeDesc: string
}
}
/**
* 유전자 필터에서 사용할 간소화된 타입
*/
interface GeneOption {
name: string
description: string
type: 'QTY' | 'QLT'
relatedTrait: string
}
interface GeneFilterModalProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedGenes: string[]
onConfirm: (genes: string[]) => void
}
export function GeneFilterModal({ open, onOpenChange, selectedGenes, onConfirm }: GeneFilterModalProps) {
const [tempSelectedGenes, setTempSelectedGenes] = useState<string[]>(selectedGenes)
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<'name' | 'type'>('name')
const [allMarkers, setAllMarkers] = useState<GeneOption[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// API에서 마커 목록 가져오기
useEffect(() => {
if (open) {
fetchMarkers()
}
}, [open])
const fetchMarkers = async () => {
try {
setLoading(true)
setError(null)
const markers = await geneApi.getAllMarkers() as unknown as MarkerData[]
// API 데이터를 GeneOption 형식으로 변환
const geneOptions: GeneOption[] = markers.map(marker => ({
name: marker.markerNm,
description: marker.relatedTrait || marker.markerDesc || '',
type: marker.markerTypeCd as 'QTY' | 'QLT',
relatedTrait: marker.relatedTrait || ''
}))
setAllMarkers(geneOptions)
} catch (err) {
console.error('Failed to fetch markers:', err)
setError('유전자 목록을 불러오는데 실패했습니다.')
} finally {
setLoading(false)
}
}
// 육량형/육질형 필터링
const quantityGenes = useMemo(() => {
return allMarkers.filter(g => g.type === 'QTY').sort((a, b) => a.name.localeCompare(b.name))
}, [allMarkers])
const qualityGenes = useMemo(() => {
return allMarkers.filter(g => g.type === 'QLT').sort((a, b) => a.name.localeCompare(b.name))
}, [allMarkers])
// 전체 유전자 목록 (정렬)
const allGenes = useMemo(() => {
return [...allMarkers].sort((a, b) => {
if (sortBy === 'type') {
if (a.type !== b.type) {
return a.type.localeCompare(b.type)
}
}
return a.name.localeCompare(b.name)
})
}, [allMarkers, sortBy])
const filteredGenes = useMemo(() => {
if (!searchQuery) return allGenes
return allGenes.filter(gene =>
gene.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
gene.description.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [allGenes, searchQuery])
const toggleGene = (geneName: string) => {
setTempSelectedGenes(prev =>
prev.includes(geneName)
? prev.filter(g => g !== geneName)
: [...prev, geneName]
)
}
const handleConfirm = () => {
onConfirm(tempSelectedGenes)
onOpenChange(false)
}
const handleCancel = () => {
setTempSelectedGenes(selectedGenes)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-2"> ...</span>
</div>
) : error ? (
<div className="text-center py-8 text-destructive">
<p>{error}</p>
<Button variant="outline" className="mt-4" onClick={fetchMarkers}>
</Button>
</div>
) : (
<Tabs defaultValue="quick" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="quick"> ({allMarkers.length})</TabsTrigger>
<TabsTrigger value="all"> </TabsTrigger>
</TabsList>
<TabsContent value="quick" className="space-y-4">
<Tabs defaultValue="quantity" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="quantity"> ({quantityGenes.length})</TabsTrigger>
<TabsTrigger value="quality"> ({qualityGenes.length})</TabsTrigger>
</TabsList>
<TabsContent value="quantity">
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
<div className="space-y-3">
{quantityGenes.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
.
</p>
) : (
quantityGenes.map((gene) => (
<div key={gene.name} className="flex items-start space-x-3">
<Checkbox
id={`quick-${gene.name}`}
checked={tempSelectedGenes.includes(gene.name)}
onCheckedChange={() => toggleGene(gene.name)}
/>
<div className="flex-1">
<Label
htmlFor={`quick-${gene.name}`}
className="text-sm font-medium cursor-pointer"
>
{gene.name}
</Label>
<p className="text-xs text-muted-foreground">{gene.description}</p>
</div>
</div>
))
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="quality">
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
<div className="space-y-3">
{qualityGenes.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
.
</p>
) : (
qualityGenes.map((gene) => (
<div key={gene.name} className="flex items-start space-x-3">
<Checkbox
id={`quick-${gene.name}`}
checked={tempSelectedGenes.includes(gene.name)}
onCheckedChange={() => toggleGene(gene.name)}
/>
<div className="flex-1">
<Label
htmlFor={`quick-${gene.name}`}
className="text-sm font-medium cursor-pointer"
>
{gene.name}
</Label>
<p className="text-xs text-muted-foreground">{gene.description}</p>
</div>
</div>
))
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="all" className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="유전자명 또는 설명 검색..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button
variant="outline"
onClick={() => setSortBy(sortBy === 'name' ? 'type' : 'name')}
>
{sortBy === 'type' ? '타입순' : '이름순'}
</Button>
</div>
<ScrollArea className="h-[300px] w-full rounded-md border p-4">
<div className="space-y-3">
{filteredGenes.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
.
</p>
) : (
filteredGenes.map((gene) => (
<div key={gene.name} className="flex items-start space-x-3">
<Checkbox
id={`all-${gene.name}`}
checked={tempSelectedGenes.includes(gene.name)}
onCheckedChange={() => toggleGene(gene.name)}
/>
<div className="flex-1">
<Label
htmlFor={`all-${gene.name}`}
className="text-sm font-medium cursor-pointer"
>
{gene.name} <span className="text-xs text-muted-foreground">({gene.type === 'QTY' ? '육량형' : '육질형'})</span>
</Label>
<p className="text-xs text-muted-foreground">{gene.description}</p>
</div>
</div>
))
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
)}
<div className="text-sm text-muted-foreground">
: {tempSelectedGenes.length}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleConfirm}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,206 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useEffect, useState } from "react"
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Filter, ChevronDown, ChevronUp } from "lucide-react"
interface GeneData {
geneName: string
geneType: '육량' | '육질' // 유전자 분류
farmRate: number // 우리 농장 우량형(AA) 보유율
regionAvgRate: number // 지역 평균
}
interface GenePossessionStatusProps {
farmNo: number | null
}
export function GenePossessionStatus({ farmNo }: GenePossessionStatusProps) {
const { selectedYear } = useAnalysisYear()
const { filters } = useGlobalFilter()
const [allGenes, setAllGenes] = useState<GeneData[]>([])
const [loading, setLoading] = useState(true)
const [isExpanded, setIsExpanded] = useState(false)
// 선택된 유전자 확인
const selectedGenes = filters.selectedGenes || []
const hasFilter = selectedGenes.length > 0
useEffect(() => {
const fetchData = async () => {
setLoading(true)
// TODO: 백엔드 API 연동 시 실제 데이터 fetch
// 현재는 목업 데이터 사용 (전체 유전자 리스트)
const mockAllGenes: GeneData[] = [
// 육량 관련
{ geneName: 'PLAG1', geneType: '육량', farmRate: 85, regionAvgRate: 72 },
{ geneName: 'NCAPG', geneType: '육량', farmRate: 82, regionAvgRate: 75 },
{ geneName: 'LCORL', geneType: '육량', farmRate: 78, regionAvgRate: 68 },
{ geneName: 'LAP3', geneType: '육량', farmRate: 65, regionAvgRate: 58 },
// 육질 관련
{ geneName: 'FABP4', geneType: '육질', farmRate: 88, regionAvgRate: 70 },
{ geneName: 'SCD', geneType: '육질', farmRate: 80, regionAvgRate: 72 },
{ geneName: 'DGAT1', geneType: '육질', farmRate: 75, regionAvgRate: 65 },
{ geneName: 'FASN', geneType: '육질', farmRate: 70, regionAvgRate: 62 },
{ geneName: 'CAPN1', geneType: '육질', farmRate: 82, regionAvgRate: 68 },
{ geneName: 'CAST', geneType: '육질', farmRate: 77, regionAvgRate: 64 },
]
// 선택된 유전자 중 목업 데이터에 없는 유전자가 있다면 추가
if (selectedGenes.length > 0) {
selectedGenes.forEach(geneName => {
if (!mockAllGenes.find(g => g.geneName === geneName)) {
// 선택된 유전자가 목업 데이터에 없으면 기본값으로 추가
mockAllGenes.push({
geneName: geneName,
geneType: geneName.includes('PLAG') || geneName.includes('NCAPG') || geneName.includes('LCORL') || geneName.includes('LAP') ? '육량' : '육질',
farmRate: Math.floor(Math.random() * 30) + 60, // 60-90 사이 랜덤값
regionAvgRate: Math.floor(Math.random() * 20) + 55, // 55-75 사이 랜덤값
})
}
})
}
setAllGenes(mockAllGenes)
setLoading(false)
}
fetchData()
}, [selectedYear, farmNo, selectedGenes])
if (loading) {
return (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground"> ...</p>
</div>
)
}
if (!farmNo) {
return (
<div className="h-[300px] flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground mb-2"> </p>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
)
}
// 필터에 따라 표시할 유전자 선택
const allDisplayGenes = hasFilter
? allGenes.filter(g => selectedGenes.includes(g.geneName))
: allGenes.slice(0, 6) // TOP 6 (보유율 높은 순으로 이미 정렬됨)
// 접기/펼치기 적용 (4개 기준)
// 단, 선택된 유전자가 있을 때는 모두 표시
const DISPLAY_LIMIT = 4
const displayGenes = hasFilter || isExpanded ? allDisplayGenes : allDisplayGenes.slice(0, DISPLAY_LIMIT)
const hasMore = !hasFilter && allDisplayGenes.length > DISPLAY_LIMIT
return (
<div className="space-y-4">
{/* 필터 배지 표시 */}
{hasFilter && (
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1.5 text-xs text-gray-600">
<Filter className="h-3.5 w-3.5" />
<span className="font-medium"> :</span>
</div>
{selectedGenes.map(gene => (
<Badge
key={gene}
variant="secondary"
className="text-xs font-medium bg-blue-50 text-blue-700 border-blue-200"
>
{gene}
</Badge>
))}
</div>
)}
{/* 유전자별 바 차트 */}
<div className="space-y-2.5">
{displayGenes.map((gene, index) => (
<div key={gene.geneName} className="space-y-1">
{/* 유전자명 + 타입 배지 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800 min-w-[60px]">
{gene.geneName}
</span>
<Badge
variant="outline"
className={`text-xs px-2 py-0 ${
gene.geneType === '육량'
? 'bg-blue-50 text-blue-700 border-blue-200'
: 'bg-purple-50 text-purple-700 border-purple-200'
}`}
>
{gene.geneType}
</Badge>
</div>
<span className="text-sm font-bold text-gray-900">
{gene.farmRate}%
</span>
</div>
{/* 프로그레스 바 */}
<div className="relative h-7 bg-gray-100 rounded-full overflow-hidden">
{/* 우리 농장 */}
<div
className={`absolute h-full transition-all duration-800 ${
gene.geneType === '육량' ? 'bg-blue-500' : 'bg-purple-500'
}`}
style={{ width: `${gene.farmRate}%` }}
/>
{/* 지역 평균 표시 (점선) */}
<div
className="absolute h-full border-l-2 border-dashed border-gray-400"
style={{ left: `${gene.regionAvgRate}%` }}
title={`지역 평균: ${gene.regionAvgRate}%`}
/>
</div>
{/* 지역 평균 레이블 */}
<div className="flex justify-end">
<span className="text-xs text-gray-500">
: {gene.regionAvgRate}%
</span>
</div>
</div>
))}
</div>
{/* 더보기/접기 버튼 */}
{hasMore && (
<div className="flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100"
>
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" />
{allDisplayGenes.length - DISPLAY_LIMIT}
</>
)}
</Button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,245 @@
'use client'
import { useState, useEffect } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Search, X, Filter, Sparkles } from "lucide-react"
import { geneApi, type MarkerModel } from "@/lib/api/gene.api"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
interface GeneSearchDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedGenes: string[]
onGenesChange: (genes: string[]) => void
}
export function GeneSearchModal({ open, onOpenChange, selectedGenes, onGenesChange }: GeneSearchDrawerProps) {
const [allGenes, setAllGenes] = useState<MarkerModel[]>([])
const [loading, setLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [filterType, setFilterType] = useState<'ALL' | 'QTY' | 'QLT'>('ALL')
// 모달 열릴 때 전체 유전자 로드
useEffect(() => {
if (open) {
loadAllGenes()
}
}, [open])
const loadAllGenes = async () => {
try {
setLoading(true)
const genes = await geneApi.getAllMarkers()
setAllGenes(genes)
} catch {
// 유전자 로드 실패 시 빈 배열 유지
} finally {
setLoading(false)
}
}
// 검색 및 필터링
const filteredGenes = allGenes.filter((gene) => {
// 타입 필터
if (filterType !== 'ALL' && gene.markerTypeCd !== filterType) {
return false
}
// 검색어 필터
if (searchQuery) {
const query = searchQuery.toLowerCase()
return (
gene.markerNm.toLowerCase().includes(query) ||
gene.markerDesc?.toLowerCase().includes(query) ||
gene.relatedTrait?.toLowerCase().includes(query)
)
}
return true
})
const toggleGene = (markerNm: string) => {
if (selectedGenes.includes(markerNm)) {
onGenesChange(selectedGenes.filter(g => g !== markerNm))
} else {
onGenesChange([...selectedGenes, markerNm])
}
}
const selectAllFiltered = () => {
const newGenes = [...selectedGenes]
filteredGenes.forEach(gene => {
if (!newGenes.includes(gene.markerNm)) {
newGenes.push(gene.markerNm)
}
})
onGenesChange(newGenes)
}
const clearAll = () => {
onGenesChange([])
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] w-full h-[95vh] flex flex-col p-0 gap-0">
{/* 헤더 */}
<DialogHeader className="px-5 pt-5 pb-3 border-b flex-shrink-0">
<div className="flex items-center gap-2.5">
<div className="p-1.5 bg-primary/10 rounded-lg">
<Sparkles className="h-4 w-4 text-primary" />
</div>
<div>
<DialogTitle className="text-lg font-bold"> </DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
<span className="font-semibold text-foreground">{allGenes.length.toLocaleString()}</span> / <span className="font-semibold text-primary">{selectedGenes.length}</span>
</DialogDescription>
</div>
</div>
</DialogHeader>
{/* 검색 및 필터 */}
<div className="px-4 py-3 space-y-3 flex-shrink-0 bg-muted/20">
{/* 검색바 */}
<div className="relative">
<Search 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 pr-9 h-10 text-sm bg-background"
autoFocus
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 hover:bg-muted rounded-full p-1 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* 필터 탭 및 액션 버튼 */}
<div className="flex items-center justify-between gap-3">
<Tabs value={filterType} onValueChange={(v) => setFilterType(v as any)} className="flex-1">
<TabsList className="w-full grid grid-cols-3 h-9">
<TabsTrigger value="ALL" className="text-xs">
<span className="ml-1 font-semibold">({allGenes.length})</span>
</TabsTrigger>
<TabsTrigger value="QTY" className="text-xs">
<div className="flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-[#2563eb]"></div>
<span className="ml-1 font-semibold">({allGenes.filter(g => g.markerTypeCd === 'QTY').length})</span>
</div>
</TabsTrigger>
<TabsTrigger value="QLT" className="text-xs">
<div className="flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-slate-400"></div>
<span className="ml-1 font-semibold">({allGenes.filter(g => g.markerTypeCd === 'QLT').length})</span>
</div>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={selectAllFiltered}
disabled={filteredGenes.length === 0}
className="h-8 text-xs px-3"
>
</Button>
<Button
variant="outline"
size="sm"
onClick={clearAll}
disabled={selectedGenes.length === 0}
className="h-8 text-xs px-3"
>
</Button>
</div>
</div>
</div>
{/* 유전자 목록 */}
<div className="flex-1 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-10 w-10 border-2 border-slate-200 border-t-[#2563eb] mx-auto mb-4"></div>
<p className="text-muted-foreground font-medium"> ...</p>
</div>
</div>
) : filteredGenes.length > 0 ? (
<ScrollArea className="h-full px-4">
<div className="flex flex-wrap gap-1.5 py-3">
{filteredGenes.map((gene) => {
const isSelected = selectedGenes.includes(gene.markerNm)
const isQuantity = gene.markerTypeCd === 'QTY'
return (
<Badge
key={gene.markerNm}
variant={isSelected ? "default" : "outline"}
className={`cursor-pointer transition-colors text-xs h-7 px-2.5 ${
isSelected
? isQuantity
? 'bg-[#2563eb] text-white hover:bg-[#2563eb]/90 border-[#2563eb]'
: 'bg-slate-400 text-white hover:bg-slate-500 border-slate-400'
: isQuantity
? 'border-[#2563eb]/40 text-[#2563eb] hover:bg-[#2563eb]/5 hover:border-[#2563eb]'
: 'border-slate-300 text-slate-600 hover:bg-slate-50 hover:border-slate-400'
}`}
onClick={() => toggleGene(gene.markerNm)}
title={`${gene.markerNm}\n${gene.markerDesc || ''}\n${gene.relatedTrait ? `관련 형질: ${gene.relatedTrait}` : ''}`}
>
{gene.markerNm}
</Badge>
)
})}
</div>
</ScrollArea>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center text-muted-foreground">
<Filter className="h-16 w-16 mx-auto mb-4 opacity-40" />
<p className="text-lg font-semibold"> </p>
<p className="text-sm mt-2"> </p>
</div>
</div>
)}
</div>
{/* 하단 버튼 */}
<div className="px-4 py-3 border-t flex justify-between items-center flex-shrink-0 bg-muted/20">
<div className="text-sm">
{searchQuery && (
<span className="text-muted-foreground mr-3">
: <span className="font-semibold text-foreground">{filteredGenes.length.toLocaleString()}</span>
</span>
)}
<span className="text-muted-foreground">
: <span className="font-bold text-primary text-base">{selectedGenes.length}</span>
</span>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 px-4">
</Button>
<Button onClick={() => onOpenChange(false)} className="h-9 px-4">
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,181 @@
'use client'
import { PieChart as PieChartIcon } from "lucide-react"
import { useEffect, useState } from "react"
import { apiClient } from "@/lib/api"
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts'
interface DistributionData {
name: string
value: number
color: string
range: string
description: string
}
interface GenomeDistributionDonutProps {
farmNo: number | null
}
export function GenomeDistributionDonut({ farmNo }: GenomeDistributionDonutProps) {
const [data, setData] = useState<DistributionData[]>([])
const [totalCount, setTotalCount] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
if (!farmNo) {
setLoading(false)
return
}
try {
const response = await apiClient.post('/cow/ranking', {
filterOptions: { farmNo },
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [
{ traitNm: '도체중', weight: 0.25 },
{ traitNm: '근내지방도', weight: 0.25 },
{ traitNm: '등심단면적', weight: 0.25 },
{ traitNm: '등지방두께', weight: 0.25 },
]
}
})
const result = response.data || response
const items = result.items || []
setTotalCount(items.length)
const distribution = {
top: 0, // 0σ 이상
middle: 0, // -1.0σ ~ 0σ
bottom: 0 // -1.0σ 이하
}
items.forEach((item: any) => {
const score = item.sortValue || 0
if (score >= 0) distribution.top++
else if (score >= -1.0) distribution.middle++
else distribution.bottom++
})
setData([
{ name: '우수', value: distribution.top, color: '#10b981', range: '0σ 이상', description: '평균보다 우수해요' },
{ name: '양호', value: distribution.middle, color: '#1482B0', range: '-1.0σ ~ 0σ', description: '평균 수준이에요' },
{ name: '개선필요', value: distribution.bottom, color: '#94a3b8', range: '-1.0σ 이하', description: '조금 더 신경써요' },
].filter(d => d.value > 0))
} catch (error) {
console.error('분포 데이터 로드 실패:', error)
} finally {
setLoading(false)
}
}
fetchData()
}, [farmNo])
if (loading) {
return (
<div className="bg-white rounded-xl border border-slate-100 p-4">
<div className="flex items-center justify-center h-[280px]">
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
</div>
</div>
)
}
return (
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
{/* 헤더 */}
<div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-violet-600 flex items-center justify-center shadow-md shadow-violet-500/20">
<PieChartIcon className="w-4 h-4 text-white" />
</div>
<div>
<h3 className="text-sm font-semibold text-slate-900"> </h3>
<p className="text-[10px] text-slate-500"> {totalCount} </p>
</div>
</div>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-lg text-[10px] font-semibold">{totalCount}</span>
</div>
</div>
{/* 차트 */}
<div className="p-5">
<div className="flex flex-col items-center">
<div className="relative w-[180px] h-[180px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={54}
outerRadius={86}
paddingAngle={3}
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} stroke="white" strokeWidth={2} />
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const item = payload[0].payload
return (
<div className="bg-slate-900 px-4 py-3 rounded-xl shadow-xl border border-slate-700">
<p className="text-white font-bold mb-2">{item.name}</p>
<p className="text-slate-200 text-sm">{item.description}</p>
<div className="mt-2 pt-2 border-t border-slate-700">
<p className="text-slate-300 text-sm">{item.value} ({Math.round(item.value / totalCount * 100)}%)</p>
<p className="text-slate-400 text-xs mt-1">{item.range}</p>
</div>
</div>
)
}
return null
}}
/>
</PieChart>
</ResponsiveContainer>
{/* 중앙 텍스트 */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold text-slate-900">{totalCount}</span>
<span className="text-xs text-slate-500 mt-1"></span>
</div>
</div>
{/* 범례 */}
<div className="w-full mt-5 pt-4 border-t border-slate-100">
<div className="space-y-2.5">
{data.map((item, idx) => (
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border border-slate-100 hover:bg-slate-100/50 transition-colors">
<div className="flex items-center gap-3">
<div className="w-4 h-4 rounded-full flex-shrink-0 shadow-sm" style={{ backgroundColor: item.color }}></div>
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-900">{item.name}</span>
<span className="text-[10px] text-slate-500">{item.description}</span>
</div>
</div>
<div className="text-right">
<p className="text-base font-bold text-slate-900">{item.value}<span className="text-xs text-slate-500 font-normal ml-0.5"></span></p>
<p className="text-[10px] text-slate-500">{Math.round(item.value / totalCount * 100)}%</p>
</div>
</div>
))}
</div>
<p className="text-[10px] text-slate-400 mt-4 text-center leading-relaxed">
σ() <br/>
0
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,250 @@
'use client'
import { Target } from "lucide-react"
import { useEffect, useState } from "react"
import { apiClient } from "@/lib/api"
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Tooltip } from 'recharts'
interface TraitScore {
trait: string
diff: number
myFarm: number
region: number
}
interface GenomeRadarChartProps {
farmNo: number | null
}
export function GenomeRadarChart({ farmNo }: GenomeRadarChartProps) {
const [data, setData] = useState<TraitScore[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
if (!farmNo) {
setLoading(false)
return
}
try {
const traits = [
{ name: '도체중', key: '도체중' },
{ name: '근내지방', key: '근내지방도' },
{ name: '등심단면적', key: '등심단면적' },
{ name: '등지방', key: '등지방두께' },
{ name: '12개월체중', key: '12개월령체중' },
]
const results: TraitScore[] = []
for (const trait of traits) {
try {
const farmResponse = await apiClient.post('/cow/ranking', {
filterOptions: { farmNo },
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
}
})
const globalResponse = await apiClient.post('/cow/ranking/global', {
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
}
})
const farmResult = farmResponse.data || farmResponse
const globalResult = globalResponse.data || globalResponse
const farmScores = farmResult.items?.map((item: any) => {
const traitDetail = item.details?.find((d: any) => d.code === trait.key)
return traitDetail?.value ?? item.sortValue ?? 0
}) || []
const farmAvgScore = farmScores.length > 0
? farmScores.reduce((sum: number, s: number) => sum + s, 0) / farmScores.length
: 0
const globalScores = globalResult.items?.map((item: any) => {
const traitDetail = item.details?.find((d: any) => d.code === trait.key)
return traitDetail?.value ?? item.sortValue ?? 0
}) || []
const regionAvgScore = globalScores.length > 0
? globalScores.reduce((sum: number, s: number) => sum + s, 0) / globalScores.length
: 0
const diff = farmAvgScore - regionAvgScore
results.push({
trait: trait.name,
diff: parseFloat(diff.toFixed(2)),
myFarm: parseFloat(farmAvgScore.toFixed(2)),
region: parseFloat(regionAvgScore.toFixed(2))
})
} catch (error) {
console.error(`형질 ${trait.name} 로드 실패:`, error)
}
}
setData(results)
} catch (error) {
console.error('레이더 차트 데이터 로드 실패:', error)
} finally {
setLoading(false)
}
}
fetchData()
}, [farmNo])
if (loading) {
return (
<div className="bg-white rounded-xl border border-slate-100 p-4">
<div className="flex items-center justify-center h-[280px]">
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
</div>
</div>
)
}
const validDiffs = data.filter(d => !isNaN(d.diff))
const avgDiff = validDiffs.length > 0
? validDiffs.reduce((sum, d) => sum + d.diff, 0) / validDiffs.length
: 0
return (
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
{/* 헤더 */}
<div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-sky-500 to-sky-600 flex items-center justify-center shadow-md shadow-sky-500/20">
<Target className="w-4 h-4 text-white" />
</div>
<div>
<h3 className="text-sm font-semibold text-slate-900"> </h3>
<p className="text-[10px] text-slate-500"> </p>
</div>
</div>
<div className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${avgDiff >= 0
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: 'bg-red-50 text-red-600 border-red-200'
}`}>
{avgDiff > 0 ? '+' : ''}{avgDiff.toFixed(2)}σ
</div>
</div>
</div>
{/* 차트 */}
<div className="p-5">
<div className="bg-gradient-to-br from-slate-50 to-blue-50/30 rounded-xl p-4">
<ResponsiveContainer width="100%" height={240}>
<RadarChart data={data} margin={{ top: 30, right: 40, bottom: 30, left: 40 }}>
<defs>
<linearGradient id="radarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#1482B0" stopOpacity={0.4} />
<stop offset="100%" stopColor="#1482B0" stopOpacity={0.1} />
</linearGradient>
</defs>
<PolarGrid
stroke="#cbd5e1"
strokeWidth={1.5}
strokeOpacity={0.5}
/>
<PolarAngleAxis
dataKey="trait"
tick={{ fontSize: 13, fill: '#334155', fontWeight: 600 }}
tickLine={false}
/>
<PolarRadiusAxis
angle={90}
domain={[-1.5, 1.5]}
tick={{ fontSize: 10, fill: '#64748b' }}
tickCount={4}
axisLine={false}
/>
<Radar
name="보은군 평균"
dataKey={() => 0}
stroke="#94a3b8"
fill="none"
strokeWidth={2}
strokeDasharray="5 3"
strokeOpacity={0.6}
/>
<Radar
name="내농장"
dataKey="diff"
stroke="#1482B0"
fill="url(#radarGradient)"
strokeWidth={3}
dot={{
fill: '#fff',
strokeWidth: 3,
stroke: '#1482B0',
r: 6,
strokeOpacity: 1
}}
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const item = payload[0]?.payload
const diff = item?.diff ?? 0
const myFarm = item?.myFarm ?? 0
const region = item?.region ?? 0
return (
<div className="bg-slate-900 px-4 py-3 rounded-xl text-sm shadow-xl border border-slate-700">
<p className="text-white font-bold mb-2">{item?.trait}</p>
<div className="space-y-1 text-slate-300 text-xs">
<p>: <span className="text-[#1482B0] font-semibold">{myFarm > 0 ? '+' : ''}{myFarm}σ</span></p>
<p>: <span className="text-slate-400">{region > 0 ? '+' : ''}{region}σ</span></p>
</div>
<div className={`mt-2 pt-2 border-t border-slate-700 font-bold ${diff >= 0.3 ? 'text-emerald-400' :
diff <= -0.3 ? 'text-amber-400' :
'text-slate-300'
}`}>
{diff >= 0.3 ? '▲' : diff <= -0.3 ? '▼' : '='} {diff > 0 ? '+' : ''}{diff.toFixed(2)}σ
</div>
</div>
)
}
return null
}}
/>
</RadarChart>
</ResponsiveContainer>
</div>
{/* 범례 */}
<div className="flex items-center justify-center gap-6 mt-4 pb-3">
<div className="flex items-center gap-2">
<div className="w-5 h-0.5 bg-slate-400 opacity-60" style={{ borderTop: '2px dashed #94a3b8' }}></div>
<span className="text-xs text-slate-600"> </span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-white border-3 border-[#1482B0] shadow-sm"></div>
<span className="text-xs text-slate-900 font-semibold"> </span>
</div>
</div>
{/* 형질별 수치 */}
<div className="grid grid-cols-5 gap-2 mt-4 pt-4 border-t border-slate-200">
{data.map((item, idx) => (
<div key={idx} className="text-center">
<p className="text-[10px] text-slate-500 mb-1 truncate font-medium">{item.trait}</p>
<p className={`text-sm font-bold ${item.diff >= 0.3 ? 'text-emerald-600' :
item.diff <= -0.3 ? 'text-amber-600' :
'text-slate-700'
}`}>
{item.diff > 0 ? '+' : ''}{item.diff.toFixed(1)}
</p>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,208 @@
'use client'
import { ArrowUpRight, ArrowDownRight, TrendingUp, TrendingDown } from "lucide-react"
import { useEffect, useState } from "react"
import { apiClient } from "@/lib/api"
interface GenomeData {
trait: string
score: number
type: string
}
interface GenomeStrengthsWeaknessesProps {
farmNo?: number | null
}
export function GenomeStrengthsWeaknesses({ farmNo }: GenomeStrengthsWeaknessesProps) {
const [allMetrics, setAllMetrics] = useState<GenomeData[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchTraitScores = async () => {
if (!farmNo) {
setLoading(false)
return
}
try {
const traits = [
{ name: '도체중', key: '도체중' },
{ name: '근내지방도', key: '근내지방도' },
{ name: '등심단면적', key: '등심단면적' },
{ name: '등지방두께', key: '등지방두께' },
{ name: '12개월령체중', key: '12개월령체중' },
{ name: '체고', key: '체고' },
]
const traitScores: GenomeData[] = []
for (const trait of traits) {
try {
const rankingRequest = {
filterOptions: { farmNo: farmNo },
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
}
}
const response = await apiClient.post('/cow/ranking', rankingRequest)
const rankingResult = response.data || response
const scores = rankingResult.items?.map((item: any) => item.sortValue) || []
const avgScore = scores.length > 0
? scores.reduce((sum: number, score: number) => sum + score, 0) / scores.length
: 0
traitScores.push({
trait: trait.name,
score: parseFloat(avgScore.toFixed(2)),
type: '유전체'
})
} catch (error) {
console.error(`[강점/약점] 형질 ${trait.name} 데이터 로드 실패:`, error)
}
}
setAllMetrics(traitScores)
} catch (error) {
console.error('형질 점수 로드 실패:', error)
setAllMetrics([])
} finally {
setLoading(false)
}
}
fetchTraitScores()
}, [farmNo])
const strengths = [...allMetrics].sort((a, b) => b.score - a.score).slice(0, 3)
const weaknesses = [...allMetrics].sort((a, b) => a.score - b.score).slice(0, 3)
if (loading) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{[1, 2].map((i) => (
<div key={i} className="bg-white rounded-xl border border-slate-100 p-4">
<div className="flex items-center justify-center h-[140px]">
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
</div>
</div>
))}
</div>
)
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 강점 */}
<div
className="bg-white rounded-xl border border-slate-100 overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-lg hover:-translate-y-1 group"
onClick={() => window.location.href = '/dashboard/strengths-weaknesses'}
>
<div className="px-5 py-4 border-b border-slate-100 bg-gradient-to-r from-emerald-50 to-transparent">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/30">
<TrendingUp className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-base font-bold text-slate-900"> </h3>
<p className="text-xs text-slate-500 mt-0.5"> </p>
</div>
</div>
<span className="px-3 py-1 bg-emerald-100 text-emerald-700 rounded-lg text-xs font-bold border border-emerald-200 shadow-sm">TOP 3</span>
</div>
</div>
<div className="p-5">
{strengths.length === 0 ? (
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
.
</div>
) : (
<div className="space-y-3">
{strengths.map((item, idx) => (
<div
key={idx}
className="flex items-center justify-between p-4 bg-gradient-to-r from-emerald-50 to-transparent rounded-xl hover:from-emerald-100 hover:to-emerald-50 transition-all duration-200 border-2 border-emerald-100 group-hover:border-emerald-200 shadow-sm"
>
<div className="flex items-center gap-3">
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shadow-md ${
idx === 0 ? 'bg-gradient-to-br from-emerald-500 to-emerald-600 text-white shadow-emerald-500/30' :
idx === 1 ? 'bg-emerald-200 text-emerald-800 border-2 border-emerald-300' :
'bg-emerald-100 text-emerald-700 border-2 border-emerald-200'
}`}>
{idx + 1}
</span>
<span className="text-sm font-bold text-slate-900">{item.trait}</span>
</div>
<div className="flex items-center gap-1 bg-white px-3 py-1.5 rounded-lg border border-emerald-200 shadow-sm">
<span className="text-lg font-bold text-emerald-600">
{item.score > 0 ? '+' : ''}{item.score}
</span>
<span className="text-xs text-emerald-500 font-semibold">σ</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* 약점 */}
<div
className="bg-white rounded-xl border border-slate-100 overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-lg hover:-translate-y-1 group"
onClick={() => window.location.href = '/dashboard/strengths-weaknesses'}
>
<div className="px-5 py-4 border-b border-slate-100 bg-gradient-to-r from-amber-50 to-transparent">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center shadow-lg shadow-amber-500/30">
<TrendingDown className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-base font-bold text-slate-900"> </h3>
<p className="text-xs text-slate-500 mt-0.5"> </p>
</div>
</div>
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-lg text-xs font-bold border border-amber-200 shadow-sm">BOTTOM 3</span>
</div>
</div>
<div className="p-5">
{weaknesses.length === 0 ? (
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
.
</div>
) : (
<div className="space-y-3">
{weaknesses.map((item, idx) => (
<div
key={idx}
className="flex items-center justify-between p-4 bg-gradient-to-r from-amber-50 to-transparent rounded-xl hover:from-amber-100 hover:to-amber-50 transition-all duration-200 border-2 border-amber-100 group-hover:border-amber-200 shadow-sm"
>
<div className="flex items-center gap-3">
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shadow-md ${
idx === 0 ? 'bg-gradient-to-br from-amber-500 to-amber-600 text-white shadow-amber-500/30' :
idx === 1 ? 'bg-amber-200 text-amber-800 border-2 border-amber-300' :
'bg-amber-100 text-amber-700 border-2 border-amber-200'
}`}>
{idx + 1}
</span>
<span className="text-sm font-bold text-slate-900">{item.trait}</span>
</div>
<div className="flex items-center gap-1 bg-white px-3 py-1.5 rounded-lg border border-amber-200 shadow-sm">
<span className="text-lg font-bold text-amber-600">
{item.score > 0 ? '+' : ''}{item.score}
</span>
<span className="text-xs text-amber-500 font-semibold">σ</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,199 @@
'use client'
import { BarChart3 } from "lucide-react"
import { useEffect, useState } from "react"
import { apiClient } from "@/lib/api"
interface TraitData {
trait: string
regional: number
myFarm: number
}
interface GenomeTraitsTableProps {
farmNo?: number | null
}
export function GenomeTraitsTable({ farmNo }: GenomeTraitsTableProps) {
const [traitData, setTraitData] = useState<TraitData[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchTraitData = async () => {
if (!farmNo) {
setLoading(false)
return
}
try {
const traits = [
{ name: '12개월령체중', key: '12개월령체중' },
{ name: '도체중', key: '도체중' },
{ name: '근내지방도', key: '근내지방도' },
{ name: '등심단면적', key: '등심단면적' },
{ name: '등지방두께', key: '등지방두께' },
]
const results: TraitData[] = []
for (const trait of traits) {
try {
const farmResponse = await apiClient.post('/cow/ranking', {
filterOptions: { farmNo: farmNo },
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
}
})
const globalResponse = await apiClient.post('/cow/ranking/global', {
rankingOptions: {
criteriaType: 'GENOME',
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
}
})
const farmResult = farmResponse.data || farmResponse
const globalResult = globalResponse.data || globalResponse
const farmScores = farmResult.items?.map((item: any) => item.sortValue) || []
const farmAvg = farmScores.length > 0
? farmScores.reduce((sum: number, score: number) => sum + score, 0) / farmScores.length
: 0
const globalScores = globalResult.items?.map((item: any) => item.sortValue) || []
const regionalAvg = globalScores.length > 0
? globalScores.reduce((sum: number, score: number) => sum + score, 0) / globalScores.length
: 0
results.push({
trait: trait.name,
myFarm: parseFloat(farmAvg.toFixed(2)),
regional: parseFloat(regionalAvg.toFixed(2))
})
} catch (error) {
console.error(`[형질 테이블] ${trait.name} 데이터 로드 실패:`, error)
}
}
setTraitData(results)
} catch (error) {
console.error('[형질 테이블] 전체 데이터 로드 실패:', error)
setTraitData([])
} finally {
setLoading(false)
}
}
fetchTraitData()
}, [farmNo])
const getTraitShortName = (name: string) => {
const shortNames: Record<string, string> = {
'12개월령체중': '12개월령체중',
'등심단면적': '등심단면적',
'등지방두께': '등지방두께',
'근내지방도': '근내지방도',
'도체중': '도체중'
}
return shortNames[name] || name
}
if (loading) {
return (
<div className="bg-white rounded-xl border border-slate-100 p-4">
<div className="flex items-center justify-center h-[180px]">
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
</div>
</div>
)
}
return (
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
{/* 헤더 */}
<div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#1482B0] to-[#0d5f82] flex items-center justify-center shadow-md shadow-[#1482B0]/20">
<BarChart3 className="w-4 h-4 text-white" />
</div>
<div>
<h3 className="text-sm font-semibold text-slate-900"> </h3>
<p className="text-[10px] text-slate-500"> </p>
</div>
</div>
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-lg text-[10px] font-semibold">5 </span>
</div>
</div>
{/* 콘텐츠 */}
<div className="p-5">
{traitData.length === 0 ? (
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
.
</div>
) : (
<div className="space-y-4">
{traitData.map((item, idx) => {
const diff = item.myFarm - item.regional
const isPositive = diff >= 0
// σ를 0~100 스케일로 변환 (-3σ~+3σ → 0~100)
const toPercent = (sigma: number) => Math.min(100, Math.max(0, ((sigma + 3) / 6) * 100))
return (
<div key={idx} className="space-y-1.5">
{/* 형질명 + 차이 */}
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-800">
{getTraitShortName(item.trait)}
</span>
<span className={`text-xs font-bold px-2.5 py-1 rounded-lg ${diff >= 0.3 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm' :
diff <= -0.3 ? 'bg-amber-50 text-amber-700 border border-amber-200 shadow-sm' :
'bg-slate-50 text-slate-700 border border-slate-200'
}`}>
{diff > 0 ? '+' : ''}{diff.toFixed(1)}σ
</span>
</div>
{/* 비교 바 */}
<div className="relative h-6 bg-slate-100 rounded-lg overflow-hidden shadow-inner">
{/* 보은군 바 */}
<div
className="absolute top-0 left-0 h-full bg-slate-300/80 rounded-lg transition-all duration-500"
style={{ width: `${toPercent(item.regional)}%` }}
/>
{/* 내농장 바 */}
<div
className={`absolute top-0 left-0 h-full rounded-lg transition-all duration-500 shadow-sm ${isPositive
? 'bg-gradient-to-r from-[#1482B0] via-[#1482B0] to-[#0d5f82]'
: 'bg-gradient-to-r from-slate-400 to-slate-500'
}`}
style={{ width: `${toPercent(item.myFarm)}%` }}
/>
</div>
{/* 라벨 */}
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-[#1482B0] shadow-sm"></div>
<span className="text-slate-600 font-medium">
<span className="font-bold text-slate-900">{item.myFarm > 0 ? '+' : ''}{item.myFarm}σ</span>
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-slate-300 shadow-sm"></div>
<span className="text-slate-500 font-medium">
{item.regional > 0 ? '+' : ''}{item.regional}σ
</span>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import React from "react"
import { useRouter } from "next/navigation"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { Separator } from "@/components/ui/separator"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useAuthStore } from "@/store/auth-store"
import { IconLogout, IconUser } from "@tabler/icons-react"
interface BreadcrumbItemData {
label: string
href?: string
}
interface AdminHeaderProps {
breadcrumbs: BreadcrumbItemData[]
}
export function AdminHeader({ breadcrumbs }: AdminHeaderProps) {
const { user, logout } = useAuthStore()
const router = useRouter()
const handleLogout = async () => {
await logout()
router.push('/login')
}
return (
<header className="flex h-16 shrink-0 items-center justify-between gap-2 border-b px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
{breadcrumbs.map((item, index) => (
<React.Fragment key={index}>
{index > 0 && <BreadcrumbSeparator className="hidden md:block" />}
<BreadcrumbItem className={index === 0 ? "hidden md:block" : ""}>
{item.href ? (
<BreadcrumbLink href={item.href}>{item.label}</BreadcrumbLink>
) : (
<BreadcrumbPage>{item.label}</BreadcrumbPage>
)}
</BreadcrumbItem>
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
</div>
{/* 사용자 메뉴 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-10 gap-2 px-3 rounded-lg hover:bg-slate-100">
<Avatar className="h-8 w-8">
<AvatarImage alt={user?.userName} />
<AvatarFallback className="text-sm font-bold bg-primary text-white">
{user?.userName?.charAt(0)?.toUpperCase() || 'A'}
</AvatarFallback>
</Avatar>
<span className="hidden md:inline text-sm font-medium text-slate-900">{user?.userName || '관리자'}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-semibold">{user?.userName || '관리자'}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem disabled>
<IconUser className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-red-600 font-medium">
<IconLogout className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
)
}

View File

@@ -0,0 +1,109 @@
"use client"
import {
IconChartBar,
IconDashboard,
IconFileUpload,
IconInnerShadowTop,
IconSettings,
IconUsers
} from "@tabler/icons-react"
import * as React from "react"
import { NavMain } from "@/components/layout/nav-main"
import { NavSecondary } from "@/components/layout/nav-secondary"
import { NavUser } from "@/components/layout/nav-user"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
const adminData = {
navMain: [
{
title: "관리자 대시보드",
url: "/admin",
icon: IconDashboard,
},
{
title: "파일 업로드",
url: "/admin/upload",
icon: IconFileUpload,
items: [
{
title: "유전체 접수내역",
url: "/admin/upload/reception",
},
{
title: "분석 의뢰 목록",
url: "/admin/upload/request-list",
},
{
title: "친자확인 결과",
url: "/admin/upload/paternity",
},
{
title: "불일치 개체 목록",
url: "/admin/upload/mismatch",
},
{
title: "유전체 분석 결과",
url: "/admin/upload/genome-result",
},
],
},
{
title: "농장주별 집계",
url: "/admin/genome-mapping",
icon: IconChartBar,
},
{
title: "사용자 관리",
url: "/admin/users",
icon: IconUsers,
},
],
navSecondary: [
{
title: "설정",
url: "/admin/settings",
icon: IconSettings,
},
],
}
export function AdminSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href="/admin">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<IconInnerShadowTop className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold"> </span>
<span className="truncate text-xs">Admin Panel</span>
</div>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={adminData.navMain} />
<NavSecondary items={adminData.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
)
}

View File

@@ -0,0 +1,103 @@
"use client"
import {
IconChartBar,
IconDashboard,
IconFileUpload,
IconInnerShadowTop,
IconListDetails,
IconShieldCheck,
IconUsers,
} from "@tabler/icons-react"
import * as React from "react"
import { NavMain } from "@/components/layout/nav-main"
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import { useAuthStore } from "@/store/auth-store"
// 일반 사용자 메뉴
const userNavMain = [
{
title: "대시보드",
url: "/dashboard",
icon: IconDashboard,
},
{
title: "개체 조회",
url: "/cow",
icon: IconListDetails,
items: [
{
title: "개체 목록",
url: "/cow",
},
],
},
]
// 관리자 메뉴
const adminNavMain = [
{
title: "관리자 대시보드",
url: "/admin",
icon: IconShieldCheck,
},
{
title: "파일 업로드",
url: "/admin/upload",
icon: IconFileUpload,
},
{
title: "농장주별 집계",
url: "/admin/genome-mapping",
icon: IconChartBar,
},
{
title: "사용자 관리",
url: "/admin/users",
icon: IconUsers,
},
]
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { user } = useAuthStore()
// 관리자 여부에 따라 다른 메뉴 표시
const isAdmin = user?.userRole === 'ADMIN'
const navMain = isAdmin ? adminNavMain : userNavMain
const homeUrl = isAdmin ? "/admin" : "/dashboard"
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href={homeUrl}>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<IconInnerShadowTop className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{isAdmin ? "관리자 페이지" : "한우 유전체 분석"}
</span>
</div>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={navMain} />
</SidebarContent>
</Sidebar>
)
}

View File

@@ -0,0 +1,51 @@
"use client"
import { type Icon } from "@tabler/icons-react"
import { usePathname } from "next/navigation"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function NavDocuments({
items,
}: {
items: {
name: string
url: string
icon: Icon
}[]
}) {
const pathname = usePathname()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel></SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.url
return (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton
asChild
tooltip={item.name}
isActive={isActive}
>
<a href={item.url}>
<Icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,56 @@
"use client"
import { type Icon } from "@tabler/icons-react"
import { usePathname } from "next/navigation"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function NavMain({
items,
}: {
items: {
title: string
url: string
icon?: Icon
items?: {
title: string
url: string
}[]
}[]
}) {
const pathname = usePathname()
return (
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.url || pathname?.startsWith(item.url + '/')
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
asChild
isActive={isActive}
>
<a href={item.url}>
{Icon && <Icon />}
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,54 @@
"use client"
import * as React from "react"
import { type Icon } from "@tabler/icons-react"
import { usePathname } from "next/navigation"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function NavSecondary({
items,
...props
}: {
items: {
title: string
url: string
icon: Icon
}[]
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
const pathname = usePathname()
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.url
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
tooltip={item.title}
isActive={isActive}
>
<a href={item.url}>
<Icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,95 @@
"use client"
import {
IconDotsVertical,
IconLogout,
} from "@tabler/icons-react"
import { useRouter } from "next/navigation"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import { useAuthStore } from "@/store/auth-store"
export function NavUser() {
const { isMobile } = useSidebar()
const router = useRouter()
const { user, logout } = useAuthStore()
// 로그인하지 않은 경우
if (!user) {
return null
}
// 사용자 이름 이니셜 생성
const getInitials = (name?: string) => {
if (!name) return 'U'
return name
.split(' ')
.map(part => part[0])
.join('')
.toUpperCase()
.slice(0, 2) || 'U'
}
const handleLogout = async () => {
try {
await logout()
router.push('/login')
} catch (error) {
console.error('로그아웃 실패:', error)
}
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg grayscale">
<AvatarImage src={undefined} alt={user.userName} />
<AvatarFallback className="rounded-lg">{getInitials(user.userName)}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.userName}</span>
<span className="text-muted-foreground truncate text-xs">
{user.userEmail}
</span>
</div>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuItem onClick={handleLogout}>
<IconLogout />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}

View File

@@ -0,0 +1,156 @@
'use client';
import { GlobalFilterDialog } from "@/components/common/global-filter-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { useAnalysisYear } from "@/contexts/AnalysisYearContext";
import { useGlobalFilter } from "@/contexts/GlobalFilterContext";
import { useAuthStore } from "@/store/auth-store";
import { Filter, LogOut, User } from "lucide-react";
import { useRouter } from "next/navigation";
export function SiteHeader() {
const { user, logout } = useAuthStore();
const router = useRouter();
const { filters } = useGlobalFilter();
const { selectedYear, setSelectedYear, availableYears } = useAnalysisYear();
const handleLogout = async () => {
await logout();
router.push('/login');
};
// 필터 활성화 여부 확인
const hasActiveFilters = filters.isActive && (
filters.selectedGenes.length > 0 ||
(filters.traitWeights && Object.values(filters.traitWeights).some(w => w > 0)) ||
(filters.inbreedingThreshold !== undefined && filters.inbreedingThreshold > 0)
);
// 필터 요약 텍스트 생성
const getFilterSummary = () => {
const parts: string[] = [];
// 유전자 - 개수만 표시
if (filters.selectedGenes.length > 0) {
parts.push(`유전자 ${filters.selectedGenes.length}`);
}
// 유전체 - 개수만 표시
if (filters.traitWeights) {
const activeWeightCount = Object.values(filters.traitWeights)
.filter(weight => weight > 0).length;
if (activeWeightCount > 0) {
parts.push(`유전체 ${activeWeightCount}`);
}
}
// 근친도 표시 (항상)
if (filters.inbreedingThreshold !== undefined && filters.inbreedingThreshold > 0) {
parts.push(`근친도 ${filters.inbreedingThreshold}%`);
}
return parts.join(' · ');
};
return (
<>
<header className="sticky top-0 z-50 w-full bg-white/80 backdrop-blur-sm border-b border-slate-200">
<div className="flex h-14 md:h-16 items-center justify-between gap-2 md:gap-3 px-3 md:px-4 lg:px-6">
{/* 왼쪽 영역 */}
<div className="flex items-center gap-2 md:gap-3 min-w-0 overflow-hidden">
<SidebarTrigger className="h-9 w-9 md:h-10 md:w-10 flex-shrink-0" />
{/* 활성화된 필터 표시 - 반응형 개선 */}
{hasActiveFilters && (
<div className="flex items-center gap-1 md:gap-2 px-1.5 md:px-3 py-1 md:py-2 bg-primary/10 rounded-md md:rounded-lg min-w-0">
<Filter className="h-4 w-4 md:h-4 md:w-4 text-primary flex-shrink-0" />
{/* 모바일: 숫자만, 데스크톱: 전체 텍스트 */}
<span className="text-[10px] md:text-sm text-primary font-medium truncate md:hidden">
{(() => {
const count = filters.selectedGenes.length +
(filters.traitWeights ? Object.values(filters.traitWeights).filter(w => w > 0).length : 0);
return count;
})()}
</span>
<span className="hidden md:inline text-sm text-primary font-medium truncate max-w-[200px] lg:max-w-[350px]">
{getFilterSummary()}
</span>
</div>
)}
</div>
{/* 오른쪽 영역 */}
<div className="flex items-center gap-2 md:gap-3 flex-shrink-0">
{/* 연도 선택 - 반응형 개선 */}
<Select
value={selectedYear.toString()}
onValueChange={(value) => setSelectedYear(Number(value))}
>
<SelectTrigger className="w-[80px] md:w-[120px] h-8 md:h-9 text-xs md:text-base font-semibold bg-slate-50 hover:bg-slate-100 border-slate-200 px-2 md:px-3">
<SelectValue>
<span className="font-medium">{selectedYear}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{availableYears.map((year) => (
<SelectItem key={year} value={year.toString()} className="text-sm md:text-base">
{year}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 필터 버튼 */}
<GlobalFilterDialog />
{/* 사용자 메뉴 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-9 w-9 md:h-10 md:w-auto md:gap-2 md:px-3 rounded-full md:rounded-lg p-0 md:p-2 hover:bg-slate-100">
<Avatar className="h-8 w-8 md:h-9 md:w-9">
<AvatarImage alt={user?.userName} />
<AvatarFallback className="text-xs md:text-sm font-bold">
{user?.userName?.charAt(0)?.toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<span className="hidden md:inline text-sm md:text-base font-semibold text-slate-900">{user?.userName || '사용자'}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 md:w-56">
<DropdownMenuLabel className="text-sm md:text-base font-semibold">{user?.userName || '사용자'}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem disabled className="text-sm md:text-base">
<User className="mr-2 h-4 w-4 md:h-5 md:w-5" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-sm md:text-base text-red-600 font-medium">
<LogOut className="mr-2 h-4 w-4 md:h-5 md:w-5" />
<span></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
</>
)
}

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-primary text-primary-foreground flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 md:h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-9 md:h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-11 md:h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-10 md:size-9",
"icon-sm": "size-9 md:size-8",
"icon-lg": "size-11 md:size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,216 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 p-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-4",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none tracking-tight font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("p-4 pt-0", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center p-4 pt-0", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,421 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { TrendingUp } from "lucide-react"
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import { cn } from "@/lib/utils"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export const description = "A bar chart"
const chartData = [
{ month: "January", desktop: 186 },
{ month: "February", desktop: 305 },
{ month: "March", desktop: 237 },
{ month: "April", desktop: 73 },
{ month: "May", desktop: 209 },
{ month: "June", desktop: 214 },
]
const chartConfig = {
desktop: {
label: "Desktop",
color: "var(--chart-1)",
},
} satisfies ChartConfig
export function ChartBarDefault() {
return (
<Card>
<CardHeader>
<CardTitle>Bar Chart</CardTitle>
<CardDescription>January - June 2024</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig}>
<BarChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => value.slice(0, 3)}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Bar dataKey="desktop" fill="var(--color-desktop)" radius={8} />
</BarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="flex gap-2 leading-none font-medium">
Trending up by 5.2% this month <TrendingUp className="h-4 w-4" />
</div>
<div className="text-muted-foreground leading-none">
Showing total visitors for the last 6 months
</div>
</CardFooter>
</Card>
)
}
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,244 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
if (errors?.length == 1) {
return errors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,36 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
interface ProgressProps extends React.ComponentProps<typeof ProgressPrimitive.Root> {
indicatorClassName?: string
}
function Progress({
className,
value,
indicatorClassName,
...props
}: ProgressProps) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("bg-primary h-full w-full flex-1 transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-md opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none p-1.5">
<XIcon className="size-6" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,730 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { Menu, PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "15em" // 288px (데스크톱 - 30% 증가)
const SIDEBAR_WIDTH_MOBILE = "16rem" // 256px (모바일 - 적당히 증가)
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="!bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden shadow-[0_2px_8px_rgba(0,0,0,0.04)]"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm shadow-[0_2px_8px_rgba(0,0,0,0.04)] border-r border-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar, isMobile } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7 md:size-7", isMobile && "size-9", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
{isMobile ? (
<Menu className="h-6 w-6" />
) : (
<PanelLeftIcon />
)}
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
/* 컴포넌트 변경 [variant=inset]:rounded-lg 둥근 모서리*/
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-5 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-10 text-sm md:h-11 md:text-base", // 반응형: 모바일 40px/14px, 데스크톱 44px/16px
sm: "h-8 text-xs md:h-9 md:text-sm", // 반응형 적용
lg: "h-12 text-base md:h-14 md:text-lg group-data-[collapsible=icon]:p-0!", // 반응형: 더 크게
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-3 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-3"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-5 shrink-0 rounded-full border-2 bg-white shadow-md transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

Some files were not shown because too many files have changed in this diff Show More