미사용 파일정리
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer"
|
||||
import { ComparisonAveragesDto, FarmTraitComparisonDto, TraitComparisonAveragesDto } from "@/lib/api"
|
||||
import { ComparisonAveragesDto, TraitComparisonAveragesDto } from "@/lib/api/genome.api"
|
||||
import { Pencil, X, RotateCcw } from 'lucide-react'
|
||||
import {
|
||||
PolarAngleAxis,
|
||||
@@ -26,34 +26,7 @@ import {
|
||||
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'],
|
||||
}
|
||||
import { DEFAULT_TRAITS, ALL_TRAITS, TRAIT_CATEGORIES } from "@/constants/traits"
|
||||
|
||||
// 형질명 표시 (전체 이름)
|
||||
const TRAIT_SHORT_NAMES: Record<string, string> = {
|
||||
@@ -121,7 +94,6 @@ interface CategoryEvaluationCardProps {
|
||||
farmAvgZ: number
|
||||
allTraits?: TraitData[]
|
||||
cowNo?: string
|
||||
traitAverages?: FarmTraitComparisonDto | null // 형질별 평균 비교 데이터 (기존)
|
||||
hideTraitCards?: boolean // 형질 카드 숨김 여부
|
||||
}
|
||||
|
||||
@@ -147,11 +119,10 @@ export function CategoryEvaluationCard({
|
||||
farmAvgZ,
|
||||
allTraits = [],
|
||||
cowNo,
|
||||
traitAverages,
|
||||
hideTraitCards = false,
|
||||
}: CategoryEvaluationCardProps) {
|
||||
// 차트에 표시할 형질 목록 (커스텀 가능)
|
||||
const [chartTraits, setChartTraits] = useState<string[]>(DEFAULT_TRAITS)
|
||||
const [chartTraits, setChartTraits] = useState<string[]>([...DEFAULT_TRAITS])
|
||||
|
||||
// 형질 추가 모달/드로어 상태
|
||||
const [isTraitSelectorOpen, setIsTraitSelectorOpen] = useState(false)
|
||||
@@ -178,7 +149,7 @@ export function CategoryEvaluationCard({
|
||||
|
||||
// 기본값으로 초기화
|
||||
const resetToDefault = () => {
|
||||
setChartTraits(DEFAULT_TRAITS)
|
||||
setChartTraits([...DEFAULT_TRAITS])
|
||||
}
|
||||
|
||||
// 폴리곤 차트용 데이터 생성 (선택된 형질 기반) - 보은군, 농가, 이 개체 비교
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import apiClient from "@/lib/api-client"
|
||||
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
|
||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
||||
import { useFilterStore } from "@/store/filter-store"
|
||||
import { useAnalysisYear } from "@/contexts/AnalysisYearContext"
|
||||
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
||||
import { ALL_TRAITS } from "@/constants/traits"
|
||||
|
||||
// 분포 데이터 타입
|
||||
interface DistributionBin {
|
||||
@@ -115,7 +116,7 @@ export function GenomeIntegratedComparison({
|
||||
}
|
||||
//===========================================================================================
|
||||
|
||||
const { filters } = useGlobalFilter()
|
||||
const { filters } = useFilterStore()
|
||||
const { selectedYear } = useAnalysisYear()
|
||||
const [stats, setStats] = useState<IntegratedStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -132,22 +133,6 @@ export function GenomeIntegratedComparison({
|
||||
}[]>([])
|
||||
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)
|
||||
|
||||
@@ -16,10 +16,8 @@ import {
|
||||
YAxis
|
||||
} from 'recharts'
|
||||
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
|
||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
||||
|
||||
// 낮을수록 좋은 형질 (부호 반전 필요)
|
||||
const NEGATIVE_TRAITS = ['등지방두께']
|
||||
import { useFilterStore } from "@/store/filter-store"
|
||||
import { NEGATIVE_TRAITS } from "@/constants/traits"
|
||||
|
||||
// 카테고리 색상 (모던 & 다이나믹 - 생동감 있는 색상)
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
@@ -188,7 +186,7 @@ export function NormalDistributionChart({
|
||||
chartFilterTrait: externalChartFilterTrait,
|
||||
onChartFilterTraitChange
|
||||
}: NormalDistributionChartProps) {
|
||||
const { filters } = useGlobalFilter()
|
||||
const { filters } = useFilterStore()
|
||||
|
||||
// 필터에서 고정된 첫 번째 형질 (없으면 첫 번째 선택된 형질, 없으면 '도체중')
|
||||
const firstPinnedTrait = filters.pinnedTraits?.[0] || selectedTraitData[0]?.name || '도체중'
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
|
||||
// 기본 7개 형질
|
||||
const DEFAULT_TRAITS = ['도체중', '등심단면적', '등지방두께', '근내지방도', '체장', '체고', '등심weight']
|
||||
|
||||
// 낮을수록 좋은 형질 (부호 반전 색상 적용)
|
||||
const NEGATIVE_TRAITS = ['등지방두께']
|
||||
import { DEFAULT_TRAITS, NEGATIVE_TRAITS } from "@/constants/traits"
|
||||
|
||||
// 형질명 표시 (전체 이름)
|
||||
const TRAIT_SHORT_NAMES: Record<string, string> = {
|
||||
|
||||
@@ -11,10 +11,13 @@ import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
||||
import { useFilterStore } from "@/store/filter-store"
|
||||
import { useMediaQuery } from "@/hooks/use-media-query"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { ComparisonAveragesDto, cowApi, geneApi, GeneDetail, genomeApi, GenomeRequestDto, TraitComparisonAveragesDto, mptApi } from "@/lib/api"
|
||||
import { cowApi } from "@/lib/api/cow.api"
|
||||
import { geneApi, GeneDetail } from "@/lib/api/gene.api"
|
||||
import { genomeApi, ComparisonAveragesDto, GenomeRequestDto, TraitComparisonAveragesDto } from "@/lib/api/genome.api"
|
||||
import { mptApi } from "@/lib/api/mpt.api"
|
||||
import { getInvalidMessage, getInvalidReason, isExcludedCow, isValidGenomeAnalysis } from "@/lib/utils/genome-analysis-config"
|
||||
import { CowDetail } from "@/types/cow.types"
|
||||
import { GenomeTrait } from "@/types/genome.types"
|
||||
@@ -145,7 +148,7 @@ export default function CowOverviewPage() {
|
||||
const cowNo = params.cowNo as string
|
||||
const from = searchParams.get('from')
|
||||
const { toast } = useToast()
|
||||
const { filters } = useGlobalFilter()
|
||||
const { filters } = useFilterStore()
|
||||
const isMobile = useMediaQuery("(max-width: 640px)")
|
||||
|
||||
const [cow, setCow] = useState<CowDetail | null>(null)
|
||||
|
||||
@@ -3,26 +3,20 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { mptApi, MptDto } from "@/lib/api"
|
||||
import { mptApi, MptDto, MptReferenceRange, MptCategory } from "@/lib/api/mpt.api"
|
||||
import { Activity, CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
||||
import { CowDetail } from "@/types/cow.types"
|
||||
import { GenomeRequestDto } from "@/lib/api"
|
||||
import { MPT_REFERENCE_RANGES } from "@/constants/mpt-reference"
|
||||
|
||||
// 혈액화학검사 카테고리별 항목
|
||||
const MPT_CATEGORIES = [
|
||||
{ name: '에너지', items: ['glucose', 'cholesterol', 'nefa', 'bcs'], color: 'bg-muted/50' },
|
||||
{ name: '단백질', items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'], color: 'bg-muted/50' },
|
||||
{ name: '간기능', items: ['ast', 'ggt', 'fattyLiverIdx'], color: 'bg-muted/50' },
|
||||
{ name: '미네랄', items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium'], color: 'bg-muted/50' },
|
||||
{ name: '별도', items: ['creatine'], color: 'bg-muted/50' },
|
||||
]
|
||||
import { GenomeRequestDto } from "@/lib/api/genome.api"
|
||||
|
||||
// 측정값 상태 판정: 안전(safe) / 주의(caution)
|
||||
function getMptValueStatus(key: string, value: number | null): 'safe' | 'caution' | 'unknown' {
|
||||
function getMptValueStatus(
|
||||
key: string,
|
||||
value: number | null,
|
||||
references: Record<string, MptReferenceRange>
|
||||
): 'safe' | 'caution' | 'unknown' {
|
||||
if (value === null || value === undefined) return 'unknown'
|
||||
const ref = MPT_REFERENCE_RANGES[key]
|
||||
const ref = references[key]
|
||||
if (!ref || ref.lowerLimit === null || ref.upperLimit === null) return 'unknown'
|
||||
// 하한값 ~ 상한값 사이면 안전, 그 외 주의
|
||||
if (value >= ref.lowerLimit && value <= ref.upperLimit) return 'safe'
|
||||
@@ -41,6 +35,25 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
const [mptData, setMptData] = useState<MptDto[]>([])
|
||||
const [selectedMpt, setSelectedMpt] = useState<MptDto | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [references, setReferences] = useState<Record<string, MptReferenceRange>>({})
|
||||
const [categories, setCategories] = useState<MptCategory[]>([])
|
||||
const [refLoading, setRefLoading] = useState(true)
|
||||
|
||||
// 참조값 로드
|
||||
useEffect(() => {
|
||||
const loadReference = async () => {
|
||||
try {
|
||||
const data = await mptApi.getReferenceValues()
|
||||
setReferences(data.references)
|
||||
setCategories(data.categories)
|
||||
} catch (error) {
|
||||
console.error('MPT 참조값 로드 실패:', error)
|
||||
} finally {
|
||||
setRefLoading(false)
|
||||
}
|
||||
}
|
||||
loadReference()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMptData = async () => {
|
||||
@@ -63,7 +76,7 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
fetchMptData()
|
||||
}, [cowNo])
|
||||
|
||||
if (loading) {
|
||||
if (loading || refLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
@@ -245,11 +258,11 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MPT_CATEGORIES.map((category) => (
|
||||
{categories.map((category) => (
|
||||
category.items.map((itemKey, itemIdx) => {
|
||||
const ref = MPT_REFERENCE_RANGES[itemKey]
|
||||
const ref = references[itemKey]
|
||||
const value = selectedMpt ? (selectedMpt[itemKey as keyof MptDto] as number | null) : null
|
||||
const status = getMptValueStatus(itemKey, value)
|
||||
const status = getMptValueStatus(itemKey, value, references)
|
||||
|
||||
return (
|
||||
<tr key={itemKey} className="border-b border-border hover:bg-muted/30">
|
||||
|
||||
@@ -8,15 +8,29 @@ 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 { cowApi } from "@/lib/api/cow.api"
|
||||
import { mptApi, MptDto, MptReferenceRange } from "@/lib/api/mpt.api"
|
||||
import { CowDetail } from "@/types/cow.types"
|
||||
import { ReproMpt } from "@/types/mpt.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"
|
||||
import { AuthGuard } from "@/components/auth/auth-guard"
|
||||
|
||||
// 측정값이 정상 범위 내인지 확인
|
||||
function isWithinRange(
|
||||
value: number,
|
||||
itemKey: string,
|
||||
references: Record<string, MptReferenceRange>
|
||||
): 'normal' | 'high' | 'low' | 'unknown' {
|
||||
const reference = references[itemKey]
|
||||
if (!reference || reference.upperLimit === null || reference.lowerLimit === null) {
|
||||
return 'unknown'
|
||||
}
|
||||
if (value > reference.upperLimit) return 'high'
|
||||
if (value < reference.lowerLimit) return 'low'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
export default function ReproductionPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
@@ -26,8 +40,25 @@ export default function ReproductionPage() {
|
||||
const { toast } = useToast()
|
||||
|
||||
const [cow, setCow] = useState<CowDetail | null>(null)
|
||||
const [reproMpt, setReproMpt] = useState<ReproMpt[]>([])
|
||||
const [mptData, setMptData] = useState<MptDto[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [references, setReferences] = useState<Record<string, MptReferenceRange>>({})
|
||||
const [refLoading, setRefLoading] = useState(true)
|
||||
|
||||
// 참조값 로드
|
||||
useEffect(() => {
|
||||
const loadReference = async () => {
|
||||
try {
|
||||
const data = await mptApi.getReferenceValues()
|
||||
setReferences(data.references)
|
||||
} catch (error) {
|
||||
console.error('MPT 참조값 로드 실패:', error)
|
||||
} finally {
|
||||
setRefLoading(false)
|
||||
}
|
||||
}
|
||||
loadReference()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -47,8 +78,8 @@ export default function ReproductionPage() {
|
||||
// 암소인 경우만 MPT 정보 조회
|
||||
if (cowData.cowSex === 'F') {
|
||||
try {
|
||||
const mptData = await reproApi.findMptByCowNo(cowNo)
|
||||
setReproMpt(mptData)
|
||||
const data = await mptApi.findByCowId(cowNo)
|
||||
setMptData(data)
|
||||
} catch (err) {
|
||||
console.error('MPT 정보 조회 실패:', err)
|
||||
}
|
||||
@@ -69,7 +100,7 @@ export default function ReproductionPage() {
|
||||
fetchData()
|
||||
}, [cowNo, toast])
|
||||
|
||||
if (loading || !cow) {
|
||||
if (loading || refLoading || !cow) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
@@ -125,27 +156,27 @@ export default function ReproductionPage() {
|
||||
}
|
||||
|
||||
// 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].creatine, fieldName: 'creatine' },
|
||||
const mptItems = mptData.length > 0 ? [
|
||||
{ name: '혈당', value: mptData[0].glucose, fieldName: 'glucose' },
|
||||
{ name: '콜레스테롤', value: mptData[0].cholesterol, fieldName: 'cholesterol' },
|
||||
{ name: 'NEFA', value: mptData[0].nefa, fieldName: 'nefa' },
|
||||
{ name: '알부민', value: mptData[0].albumin, fieldName: 'albumin' },
|
||||
{ name: '총글로불린', value: mptData[0].globulin, fieldName: 'globulin' },
|
||||
{ name: 'A/G', value: mptData[0].agRatio, fieldName: 'agRatio' },
|
||||
{ name: '요소태질소(BUN)', value: mptData[0].bun, fieldName: 'bun' },
|
||||
{ name: 'AST', value: mptData[0].ast, fieldName: 'ast' },
|
||||
{ name: 'GGT', value: mptData[0].ggt, fieldName: 'ggt' },
|
||||
{ name: '지방간 지수', value: mptData[0].fattyLiverIdx, fieldName: 'fattyLiverIdx' },
|
||||
{ name: '칼슘', value: mptData[0].calcium, fieldName: 'calcium' },
|
||||
{ name: '인', value: mptData[0].phosphorus, fieldName: 'phosphorus' },
|
||||
{ name: '칼슘/인', value: mptData[0].caPRatio, fieldName: 'caPRatio' },
|
||||
{ name: '마그네슘', value: mptData[0].magnesium, fieldName: 'magnesium' },
|
||||
{ name: '크레아틴', value: mptData[0].creatine, fieldName: 'creatine' },
|
||||
] : []
|
||||
|
||||
const normalItems = mptItems.filter(item => {
|
||||
if (item.value === undefined || item.value === null) return false
|
||||
return isWithinRange(item.value, item.fieldName) === 'normal'
|
||||
return isWithinRange(item.value, item.fieldName, references) === 'normal'
|
||||
})
|
||||
|
||||
const healthScore = mptItems.length > 0 ? Math.round((normalItems.length / mptItems.length) * 100) : 0
|
||||
@@ -173,7 +204,7 @@ export default function ReproductionPage() {
|
||||
</div>
|
||||
|
||||
{/* MPT 혈액검사 결과 */}
|
||||
{reproMpt.length > 0 ? (
|
||||
{mptData.length > 0 ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -202,16 +233,16 @@ export default function ReproductionPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>MPT 혈액검사 상세</CardTitle>
|
||||
<CardDescription>
|
||||
검사일: {reproMpt[0].reproMptDate
|
||||
? new Date(reproMpt[0].reproMptDate).toLocaleDateString('ko-KR')
|
||||
검사일: {mptData[0].testDt
|
||||
? new Date(mptData[0].testDt).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]
|
||||
const isNormal = item.value !== undefined && item.value !== null && isWithinRange(item.value, item.fieldName, references) === 'normal'
|
||||
const reference = references[item.fieldName]
|
||||
|
||||
return (
|
||||
<div key={idx} className={`p-3 rounded-lg border ${isNormal ? 'bg-green-50 border-green-200' : 'bg-orange-50 border-orange-200'}`}>
|
||||
@@ -233,13 +264,6 @@ export default function ReproductionPage() {
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
</>
|
||||
|
||||
@@ -19,9 +19,9 @@ import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { cowApi } from "@/lib/api"
|
||||
import { cowApi } from "@/lib/api/cow.api"
|
||||
import { useAuthStore } from "@/store/auth-store"
|
||||
import { useGlobalFilter, GlobalFilterProvider } from "@/contexts/GlobalFilterContext"
|
||||
import { useFilterStore } from "@/store/filter-store"
|
||||
import { AnalysisYearProvider } from "@/contexts/AnalysisYearContext"
|
||||
import { AuthGuard } from "@/components/auth/auth-guard"
|
||||
|
||||
@@ -70,7 +70,7 @@ function MyCowContent() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
const { user } = useAuthStore()
|
||||
const { filters, isLoading: isFilterLoading } = useGlobalFilter()
|
||||
const { filters, isLoading: isFilterLoading } = useFilterStore()
|
||||
const [markerTypes, setMarkerTypes] = useState<Record<string, string>>({}) // 마커명 → 타입(QTY/QLT) 매핑
|
||||
|
||||
// 로컬 필터 상태 (검색, 랭킹모드, 정렬)
|
||||
@@ -264,7 +264,7 @@ function MyCowContent() {
|
||||
},
|
||||
rankingOptions
|
||||
}
|
||||
|
||||
// 백엔드 ranking API 호출
|
||||
const response = await cowApi.getRanking(rankingRequest)
|
||||
|
||||
// ==========================================================================================================
|
||||
@@ -952,7 +952,7 @@ function MyCowContent() {
|
||||
<th className="cow-table-header" style={{ width: '60px' }}>성별</th>
|
||||
<th className="cow-table-header" style={{ width: '100px' }}>모개체번호</th>
|
||||
<th className="cow-table-header" style={{ width: '90px' }}>아비 KPN</th>
|
||||
<th className="cow-table-header" style={{ width: '80px' }}>
|
||||
<th className="cow-table-header" style={{ width: '100px', whiteSpace: 'nowrap' }}>
|
||||
{analysisFilter === 'mptOnly' ? '월령(검사일)' : '월령(분석일)'}
|
||||
</th>
|
||||
<th className="cow-table-header" style={{ width: '90px' }}>
|
||||
@@ -1505,11 +1505,11 @@ function MyCowContent() {
|
||||
export default function MyCowPage() {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<GlobalFilterProvider>
|
||||
|
||||
<AnalysisYearProvider>
|
||||
<MyCowContent />
|
||||
</AnalysisYearProvider>
|
||||
</GlobalFilterProvider>
|
||||
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { apiClient, farmApi } from "@/lib/api"
|
||||
import apiClient from "@/lib/api-client"
|
||||
import { farmApi } from "@/lib/api/farm.api"
|
||||
import { DashboardStatsDto, FarmRegionRankingDto, YearlyTraitTrendDto, genomeApi } from "@/lib/api/genome.api"
|
||||
import { mptApi, MptStatisticsDto } from "@/lib/api/mpt.api"
|
||||
import { useAuthStore } from "@/store/auth-store"
|
||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
||||
import { useFilterStore } from "@/store/filter-store"
|
||||
import { TRAIT_CATEGORIES, NEGATIVE_TRAITS } from "@/constants/traits"
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
@@ -50,22 +52,10 @@ import {
|
||||
YAxis
|
||||
} from 'recharts'
|
||||
|
||||
// 카테고리별 형질 목록 (백엔드 TRAIT_CATEGORY_MAP과 일치)
|
||||
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 NEGATIVE_TRAITS = ['등지방두께']
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
const { user } = useAuthStore()
|
||||
const { filters } = useGlobalFilter()
|
||||
const { filters } = useFilterStore()
|
||||
const [farmNo, setFarmNo] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [stats, setStats] = useState<DashboardStatsDto | null>(null)
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Image from "next/image";
|
||||
import { User, Mail, ArrowLeft } from "lucide-react";
|
||||
|
||||
// 시안 1: 현재 디자인
|
||||
function FindIdDesign1() {
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
||||
<h1 className="text-2xl font-bold">아이디 찾기</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
가입 시 등록한 이름과 이메일을 입력해주세요
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이름</label>
|
||||
<Input placeholder="홍길동" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이메일</label>
|
||||
<Input type="email" placeholder="example@email.com" />
|
||||
<p className="text-xs text-gray-500">가입 시 등록한 이메일 주소를 입력해주세요</p>
|
||||
</div>
|
||||
<Button className="w-full">인증번호 발송</Button>
|
||||
<Button variant="outline" className="w-full border-2 border-primary text-primary">로그인</Button>
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t" /></div>
|
||||
<div className="relative flex justify-center text-xs"><span className="bg-white px-2 text-gray-500">계정이 없으신가요?</span></div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full border-2 border-primary text-primary">회원가입</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 2: 아이콘 + 간결한 레이아웃
|
||||
function FindIdDesign2() {
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
||||
<h1 className="text-2xl font-bold">아이디 찾기</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
가입 시 등록한 정보로 아이디를 찾을 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이름</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input placeholder="이름을 입력하세요" className="pl-10 h-11" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이메일</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input type="email" placeholder="이메일을 입력하세요" className="pl-10 h-11" />
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full h-11">인증번호 발송</Button>
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t" /></div>
|
||||
<div className="relative flex justify-center text-xs"><span className="bg-white px-2 text-gray-500">또는</span></div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full h-11 border-2 border-primary text-primary">로그인으로 돌아가기</Button>
|
||||
<div className="text-center">
|
||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">비밀번호를 잊으셨나요?</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 3: 뒤로가기 버튼 + 깔끔한 구조
|
||||
function FindIdDesign3() {
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="#" className="flex items-center gap-1 text-sm text-gray-500 hover:text-primary mb-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
로그인으로 돌아가기
|
||||
</a>
|
||||
<div className="flex flex-col gap-1 mb-2">
|
||||
<h1 className="text-2xl font-bold">아이디 찾기</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
가입 시 등록한 이름과 이메일을 입력해주세요
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이름</label>
|
||||
<Input placeholder="이름을 입력하세요" className="h-11" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이메일</label>
|
||||
<Input type="email" placeholder="이메일을 입력하세요" className="h-11" />
|
||||
</div>
|
||||
<Button className="w-full h-11 mt-2">인증번호 발송</Button>
|
||||
<div className="flex items-center justify-center gap-4 text-sm text-gray-500 mt-2">
|
||||
<a href="#" className="hover:text-primary">비밀번호 찾기</a>
|
||||
<span>|</span>
|
||||
<a href="#" className="hover:text-primary">회원가입</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 4: 로그인과 통일된 스타일 (추천)
|
||||
function FindIdDesign4() {
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
||||
<h1 className="text-2xl font-bold">아이디 찾기</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
가입 시 등록한 정보를 입력해주세요
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이름</label>
|
||||
<Input placeholder="이름을 입력하세요" className="h-11" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">이메일</label>
|
||||
<a href="#" className="text-xs text-primary hover:underline">비밀번호 찾기</a>
|
||||
</div>
|
||||
<Input type="email" placeholder="이메일을 입력하세요" className="h-11" />
|
||||
</div>
|
||||
<Button className="w-full h-11">인증번호 발송</Button>
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t" /></div>
|
||||
<div className="relative flex justify-center text-xs"><span className="bg-white px-2 text-gray-500">또는</span></div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full h-11 border-2 border-primary text-primary">로그인</Button>
|
||||
<div className="text-center">
|
||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">계정이 없으신가요? 회원가입</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FindIdDemo() {
|
||||
const designs = [
|
||||
{ id: "current", name: "현재", description: "현재 적용된 디자인", features: ["기존 레이아웃"], component: FindIdDesign1 },
|
||||
{ id: "icon", name: "시안 2", description: "아이콘 + 간결한 레이아웃", features: ["입력 필드 아이콘", "간결한 하단 링크"], component: FindIdDesign2 },
|
||||
{ id: "back", name: "시안 3", description: "뒤로가기 버튼 + 좌측 정렬 제목", features: ["뒤로가기 버튼", "좌측 정렬"], component: FindIdDesign3 },
|
||||
{ id: "unified", name: "시안 4", description: "로그인과 통일된 스타일 (추천)", features: ["로그인 스타일 통일", "비밀번호 찾기 위치"], component: FindIdDesign4 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">아이디 찾기 페이지 디자인 시안</h1>
|
||||
<p className="text-gray-600 mt-2">각 탭을 클릭하여 다른 디자인 시안을 비교해보세요</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="current" className="space-y-6">
|
||||
<TabsList className="grid grid-cols-4 w-full h-auto p-1">
|
||||
{designs.map((design) => (
|
||||
<TabsTrigger key={design.id} value={design.id} className="py-3 text-xs sm:text-sm data-[state=active]:bg-primary data-[state=active]:text-white">
|
||||
{design.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{designs.map((design) => (
|
||||
<TabsContent key={design.id} value={design.id} className="space-y-4">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{design.name}: {design.description}</h2>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{design.features.map((feature, idx) => (
|
||||
<Badge key={idx} variant="secondary">{feature}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<design.component />
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Image from "next/image";
|
||||
import { User, Mail, ArrowLeft, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
// 시안 1: 현재 디자인
|
||||
function FindPwDesign1() {
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
||||
<h1 className="text-2xl font-bold">비밀번호 찾기</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
아이디와 이메일을 입력해주세요
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">아이디</label>
|
||||
<Input placeholder="아이디를 입력하세요" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이메일</label>
|
||||
<Input type="email" placeholder="이메일 주소를 입력해주세요" />
|
||||
<p className="text-xs text-gray-500">가입 시 등록한 이메일 주소를 입력해주세요</p>
|
||||
</div>
|
||||
<Button className="w-full">인증번호 발송</Button>
|
||||
<Button variant="outline" className="w-full border-2 border-primary text-primary">로그인</Button>
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t" /></div>
|
||||
<div className="relative flex justify-center text-xs"><span className="bg-white px-2 text-gray-500">계정이 없으신가요?</span></div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full border-2 border-primary text-primary">회원가입</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 2: 아이콘 + 간결한 레이아웃
|
||||
function FindPwDesign2() {
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
||||
<h1 className="text-2xl font-bold">비밀번호 찾기</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
등록된 정보로 비밀번호를 재설정할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">아이디</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input placeholder="아이디를 입력하세요" className="pl-10 h-11" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이메일</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input type="email" placeholder="이메일을 입력하세요" className="pl-10 h-11" />
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full h-11">인증번호 발송</Button>
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t" /></div>
|
||||
<div className="relative flex justify-center text-xs"><span className="bg-white px-2 text-gray-500">또는</span></div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full h-11 border-2 border-primary text-primary">로그인으로 돌아가기</Button>
|
||||
<div className="text-center">
|
||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">아이디를 잊으셨나요?</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 3: 뒤로가기 버튼 + 좌측 정렬
|
||||
function FindPwDesign3() {
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="#" className="flex items-center gap-1 text-sm text-gray-500 hover:text-primary mb-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
로그인으로 돌아가기
|
||||
</a>
|
||||
<div className="flex flex-col gap-1 mb-2">
|
||||
<h1 className="text-2xl font-bold">비밀번호 찾기</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
아이디와 이메일을 입력해주세요
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">아이디</label>
|
||||
<Input placeholder="아이디를 입력하세요" className="h-11" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이메일</label>
|
||||
<Input type="email" placeholder="이메일을 입력하세요" className="h-11" />
|
||||
</div>
|
||||
<Button className="w-full h-11 mt-2">인증번호 발송</Button>
|
||||
<div className="flex items-center justify-center gap-4 text-sm text-gray-500 mt-2">
|
||||
<a href="#" className="hover:text-primary">아이디 찾기</a>
|
||||
<span>|</span>
|
||||
<a href="#" className="hover:text-primary">회원가입</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 4: 로그인과 통일된 스타일 (추천)
|
||||
function FindPwDesign4() {
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
||||
<h1 className="text-2xl font-bold">비밀번호 찾기</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
등록된 정보를 입력해주세요
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">아이디</label>
|
||||
<Input placeholder="아이디를 입력하세요" className="h-11" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">이메일</label>
|
||||
<a href="#" className="text-xs text-primary hover:underline">아이디 찾기</a>
|
||||
</div>
|
||||
<Input type="email" placeholder="이메일을 입력하세요" className="h-11" />
|
||||
</div>
|
||||
<Button className="w-full h-11">인증번호 발송</Button>
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t" /></div>
|
||||
<div className="relative flex justify-center text-xs"><span className="bg-white px-2 text-gray-500">또는</span></div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full h-11 border-2 border-primary text-primary">로그인</Button>
|
||||
<div className="text-center">
|
||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">계정이 없으신가요? 회원가입</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FindPwDemo() {
|
||||
const designs = [
|
||||
{ id: "current", name: "현재", description: "현재 적용된 디자인", features: ["기존 레이아웃"], component: FindPwDesign1 },
|
||||
{ id: "icon", name: "시안 2", description: "아이콘 + 간결한 레이아웃", features: ["입력 필드 아이콘", "간결한 하단 링크"], component: FindPwDesign2 },
|
||||
{ id: "back", name: "시안 3", description: "뒤로가기 버튼 + 좌측 정렬 제목", features: ["뒤로가기 버튼", "좌측 정렬"], component: FindPwDesign3 },
|
||||
{ id: "unified", name: "시안 4", description: "로그인과 통일된 스타일 (추천)", features: ["로그인 스타일 통일", "아이디 찾기 위치"], component: FindPwDesign4 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">비밀번호 찾기 페이지 디자인 시안</h1>
|
||||
<p className="text-gray-600 mt-2">각 탭을 클릭하여 다른 디자인 시안을 비교해보세요</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="current" className="space-y-6">
|
||||
<TabsList className="grid grid-cols-4 w-full h-auto p-1">
|
||||
{designs.map((design) => (
|
||||
<TabsTrigger key={design.id} value={design.id} className="py-3 text-xs sm:text-sm data-[state=active]:bg-primary data-[state=active]:text-white">
|
||||
{design.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{designs.map((design) => (
|
||||
<TabsContent key={design.id} value={design.id} className="space-y-4">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{design.name}: {design.description}</h2>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{design.features.map((feature, idx) => (
|
||||
<Badge key={idx} variant="secondary">{feature}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<design.component />
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,549 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Image from "next/image";
|
||||
import { LogIn, Eye, EyeOff, User, Lock } from "lucide-react";
|
||||
|
||||
// 시안 1: 현재 디자인
|
||||
function LoginDesign1() {
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image
|
||||
src="/logo-graphic.svg"
|
||||
alt="로고"
|
||||
fill
|
||||
className="object-contain p-16"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
||||
<h1 className="text-2xl font-bold">로그인</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
한우 유전능력 컨설팅 서비스에 오신 것을 환영합니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">아이디</label>
|
||||
<Input placeholder="아이디를 입력하세요" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">비밀번호</label>
|
||||
<Input type="password" placeholder="비밀번호를 입력하세요" />
|
||||
</div>
|
||||
<Button className="w-full">로그인</Button>
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-muted-foreground">계정이 없으신가요?</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full border-2 border-primary text-primary">
|
||||
회원가입
|
||||
</Button>
|
||||
<div className="text-center text-sm">
|
||||
<a href="#" className="hover:underline">아이디 찾기</a>
|
||||
{" | "}
|
||||
<a href="#" className="hover:underline">비밀번호 찾기</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 2: 현재 + 비밀번호 토글 + 아이콘
|
||||
function LoginDesign2() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image
|
||||
src="/logo-graphic.svg"
|
||||
alt="로고"
|
||||
fill
|
||||
className="object-contain p-16"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
||||
<h1 className="text-2xl font-bold">로그인</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
한우 유전능력 컨설팅 서비스에 오신 것을 환영합니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">아이디</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input placeholder="아이디를 입력하세요" className="pl-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">비밀번호</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full">
|
||||
<LogIn className="w-4 h-4 mr-2" />
|
||||
로그인
|
||||
</Button>
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-muted-foreground">계정이 없으신가요?</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full border-2 border-primary text-primary">
|
||||
회원가입
|
||||
</Button>
|
||||
<div className="text-center text-sm">
|
||||
<a href="#" className="hover:underline">아이디 찾기</a>
|
||||
{" | "}
|
||||
<a href="#" className="hover:underline">비밀번호 찾기</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 3: 현재 + 더 큰 입력 필드 + 부드러운 그림자
|
||||
function LoginDesign3() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-gradient-to-br from-slate-50 to-slate-100 relative hidden lg:flex items-center justify-center">
|
||||
<Image
|
||||
src="/logo-graphic.svg"
|
||||
alt="로고"
|
||||
fill
|
||||
className="object-contain p-16"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-12">
|
||||
<div className="w-full max-w-[360px]">
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col items-center gap-2 text-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900">로그인</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
한우 유전능력 컨설팅 서비스에 오신 것을 환영합니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">아이디</label>
|
||||
<Input
|
||||
placeholder="아이디를 입력하세요"
|
||||
className="h-12 text-base shadow-sm border-gray-200 focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">비밀번호</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
className="h-12 text-base pr-10 shadow-sm border-gray-200 focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full h-12 text-base shadow-md hover:shadow-lg transition-shadow">
|
||||
로그인
|
||||
</Button>
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="bg-white px-3 text-gray-500">계정이 없으신가요?</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full h-12 text-base border-2 border-primary text-primary hover:bg-primary hover:text-white transition-colors">
|
||||
회원가입
|
||||
</Button>
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<a href="#" className="hover:text-primary transition-colors">아이디 찾기</a>
|
||||
<span className="mx-2">|</span>
|
||||
<a href="#" className="hover:text-primary transition-colors">비밀번호 찾기</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 4: 현재 + 아이디 저장 + 간결한 링크
|
||||
function LoginDesign4() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image
|
||||
src="/logo-graphic.svg"
|
||||
alt="로고"
|
||||
fill
|
||||
className="object-contain p-16"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
||||
<h1 className="text-2xl font-bold">로그인</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
한우 유전능력 컨설팅 서비스에 오신 것을 환영합니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">아이디</label>
|
||||
<Input placeholder="아이디를 입력하세요" className="h-11" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">비밀번호</label>
|
||||
<a href="#" className="text-xs text-primary hover:underline">비밀번호 찾기</a>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
className="h-11 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" className="rounded border-gray-300 text-primary focus:ring-primary" />
|
||||
<span className="text-sm text-gray-600">아이디 저장</span>
|
||||
</label>
|
||||
<Button className="w-full h-11">로그인</Button>
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-muted-foreground">또는</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full h-11 border-2 border-primary text-primary">
|
||||
회원가입
|
||||
</Button>
|
||||
<div className="text-center">
|
||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">아이디를 잊으셨나요?</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 5: 현재 + 컬러 강조 배경
|
||||
function LoginDesign5() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-primary/5 relative hidden lg:flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 via-transparent to-primary/5" />
|
||||
<Image
|
||||
src="/logo-graphic.svg"
|
||||
alt="로고"
|
||||
fill
|
||||
className="object-contain p-16"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
||||
<h1 className="text-2xl font-bold text-primary">로그인</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
한우 유전능력 컨설팅 서비스에 오신 것을 환영합니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">아이디</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary/60" />
|
||||
<Input
|
||||
placeholder="아이디를 입력하세요"
|
||||
className="pl-10 h-11 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">비밀번호</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary/60" />
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
className="pl-10 pr-10 h-11 border-primary/20 focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-primary"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full h-11 bg-primary hover:bg-primary/90">
|
||||
<LogIn className="w-4 h-4 mr-2" />
|
||||
로그인
|
||||
</Button>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" className="rounded border-primary/30 text-primary focus:ring-primary" />
|
||||
<span className="text-gray-600">아이디 저장</span>
|
||||
</label>
|
||||
<a href="#" className="text-primary hover:underline">비밀번호 찾기</a>
|
||||
</div>
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-primary/20" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="bg-white px-2 text-gray-500">계정이 없으신가요?</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full h-11 border-2 border-primary text-primary hover:bg-primary/5">
|
||||
회원가입
|
||||
</Button>
|
||||
<div className="text-center">
|
||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">아이디 찾기</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 6: 현재 + 라운드 스타일
|
||||
function LoginDesign6() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image
|
||||
src="/logo-graphic.svg"
|
||||
alt="로고"
|
||||
fill
|
||||
className="object-contain p-16"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-slate-50">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-12">
|
||||
<div className="w-full max-w-[360px] bg-white p-8 rounded-2xl shadow-lg">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center mb-2">
|
||||
<h1 className="text-2xl font-bold">로그인</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
한우 유전능력 컨설팅 서비스
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">아이디</label>
|
||||
<Input
|
||||
placeholder="아이디를 입력하세요"
|
||||
className="h-11 rounded-xl bg-slate-50 border-0 focus:bg-white focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">비밀번호</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
className="h-11 pr-10 rounded-xl bg-slate-50 border-0 focus:bg-white focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" className="rounded-md border-gray-300" />
|
||||
<span className="text-gray-600">아이디 저장</span>
|
||||
</label>
|
||||
<a href="#" className="text-primary hover:underline">비밀번호 찾기</a>
|
||||
</div>
|
||||
<Button className="w-full h-11 rounded-xl">로그인</Button>
|
||||
<Button variant="outline" className="w-full h-11 rounded-xl border-2">
|
||||
회원가입
|
||||
</Button>
|
||||
<div className="text-center">
|
||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">아이디 찾기</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthPagesDemo() {
|
||||
const designs = [
|
||||
{
|
||||
id: "current",
|
||||
name: "현재",
|
||||
description: "현재 적용된 디자인",
|
||||
features: ["기존 레이아웃"],
|
||||
component: LoginDesign1
|
||||
},
|
||||
{
|
||||
id: "icon",
|
||||
name: "시안 2",
|
||||
description: "아이콘 + 비밀번호 토글 추가",
|
||||
features: ["입력 필드 아이콘", "비밀번호 보기"],
|
||||
component: LoginDesign2
|
||||
},
|
||||
{
|
||||
id: "large",
|
||||
name: "시안 3",
|
||||
description: "더 큰 입력 필드 + 그림자",
|
||||
features: ["h-12 입력필드", "그림자 효과", "부드러운 배경"],
|
||||
component: LoginDesign3
|
||||
},
|
||||
{
|
||||
id: "save",
|
||||
name: "시안 4",
|
||||
description: "아이디 저장 + 간결한 링크",
|
||||
features: ["아이디 저장", "비밀번호 찾기 위치 변경"],
|
||||
component: LoginDesign4
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
name: "시안 5",
|
||||
description: "브랜드 컬러 강조",
|
||||
features: ["컬러 배경", "컬러 아이콘", "컬러 제목"],
|
||||
component: LoginDesign5
|
||||
},
|
||||
{
|
||||
id: "round",
|
||||
name: "시안 6",
|
||||
description: "라운드 카드 스타일",
|
||||
features: ["라운드 입력필드", "카드 레이아웃", "부드러운 그림자"],
|
||||
component: LoginDesign6
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">로그인 페이지 디자인 시안</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
현재 디자인 기반 개선안 - 각 탭을 클릭하여 비교해보세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="current" className="space-y-6">
|
||||
<TabsList className="grid grid-cols-6 w-full h-auto p-1">
|
||||
{designs.map((design) => (
|
||||
<TabsTrigger
|
||||
key={design.id}
|
||||
value={design.id}
|
||||
className="py-3 text-xs sm:text-sm data-[state=active]:bg-primary data-[state=active]:text-white"
|
||||
>
|
||||
{design.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{designs.map((design) => (
|
||||
<TabsContent key={design.id} value={design.id} className="space-y-4">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{design.name}: {design.description}</h2>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{design.features.map((feature, idx) => (
|
||||
<Badge key={idx} variant="secondary">{feature}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<design.component />
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Image from "next/image";
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
// 시안 1: 현재 디자인 (3단계 스텝)
|
||||
function SignupDesign1() {
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<h1 className="text-2xl font-bold">회원가입</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{step === 1 && "기본 정보"}
|
||||
{step === 2 && "이메일 인증"}
|
||||
{step === 3 && "추가 정보"}
|
||||
</p>
|
||||
</div>
|
||||
{/* 스텝 인디케이터 */}
|
||||
<div className="flex items-center justify-center gap-2 py-2">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div key={s} className="flex items-center">
|
||||
<div className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium",
|
||||
step === s ? "bg-primary text-white" : step > s ? "bg-primary/20 text-primary" : "bg-gray-100 text-gray-400"
|
||||
)}>
|
||||
{step > s ? <CheckCircle2 className="w-4 h-4" /> : s}
|
||||
</div>
|
||||
{s < 3 && <div className={cn("w-8 h-0.5 mx-1", step > s ? "bg-primary/20" : "bg-gray-200")} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">회원 유형 *</label>
|
||||
<Select><SelectTrigger><SelectValue placeholder="회원 유형을 선택하세요" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="FARM">농가</SelectItem><SelectItem value="CNSLT">컨설턴트</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">아이디 *</label>
|
||||
<Input placeholder="아이디를 입력하세요 (4자 이상)" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이름 *</label>
|
||||
<Input placeholder="이름을 입력하세요" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이메일 *</label>
|
||||
<Input type="email" placeholder="이메일을 입력하세요" />
|
||||
<Button variant="outline" className="w-full">인증번호 발송</Button>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">휴대폰 번호 *</label>
|
||||
<Input placeholder="010-0000-0000" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">비밀번호 *</label>
|
||||
<Input type="password" placeholder="비밀번호 (8자 이상)" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex gap-2 pt-2">
|
||||
{step > 1 && (
|
||||
<Button variant="outline" onClick={() => setStep(s => s - 1)} className="flex-1">
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />이전
|
||||
</Button>
|
||||
)}
|
||||
{step < 3 ? (
|
||||
<Button onClick={() => setStep(s => s + 1)} className="flex-1">다음<ChevronRight className="w-4 h-4 ml-1" /></Button>
|
||||
) : (
|
||||
<Button className="flex-1">회원가입</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" className="w-full">로그인으로 돌아가기</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 2: 스텝에 라벨 추가
|
||||
function SignupDesign2() {
|
||||
const [step, setStep] = useState(1);
|
||||
const steps = [{ num: 1, label: "기본정보" }, { num: 2, label: "이메일인증" }, { num: 3, label: "추가정보" }];
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[360px]">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<h1 className="text-2xl font-bold">회원가입</h1>
|
||||
</div>
|
||||
{/* 스텝 인디케이터 with 라벨 */}
|
||||
<div className="flex items-center justify-between py-4">
|
||||
{steps.map((s, idx) => (
|
||||
<div key={s.num} className="flex flex-col items-center flex-1">
|
||||
<div className="flex items-center w-full">
|
||||
{idx > 0 && <div className={cn("flex-1 h-0.5", step > idx ? "bg-primary" : "bg-gray-200")} />}
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium shrink-0",
|
||||
step === s.num ? "bg-primary text-white" : step > s.num ? "bg-primary text-white" : "bg-gray-100 text-gray-400"
|
||||
)}>
|
||||
{step > s.num ? <CheckCircle2 className="w-5 h-5" /> : s.num}
|
||||
</div>
|
||||
{idx < 2 && <div className={cn("flex-1 h-0.5", step > s.num ? "bg-primary" : "bg-gray-200")} />}
|
||||
</div>
|
||||
<span className={cn("text-xs mt-2", step >= s.num ? "text-primary font-medium" : "text-gray-400")}>{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">회원 유형 *</label>
|
||||
<Select><SelectTrigger className="h-11"><SelectValue placeholder="회원 유형을 선택하세요" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="FARM">농가</SelectItem><SelectItem value="CNSLT">컨설턴트</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">아이디 *</label>
|
||||
<Input placeholder="아이디를 입력하세요 (4자 이상)" className="h-11" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이름 *</label>
|
||||
<Input placeholder="이름을 입력하세요" className="h-11" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이메일 *</label>
|
||||
<Input type="email" placeholder="이메일을 입력하세요" className="h-11" />
|
||||
</div>
|
||||
<Button variant="outline" className="w-full h-11">인증번호 발송</Button>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">휴대폰 번호 *</label>
|
||||
<Input placeholder="010-0000-0000" className="h-11" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">비밀번호 *</label>
|
||||
<Input type="password" placeholder="비밀번호 (8자 이상)" className="h-11" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">비밀번호 확인 *</label>
|
||||
<Input type="password" placeholder="비밀번호를 다시 입력하세요" className="h-11" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex gap-2 pt-2">
|
||||
{step > 1 && (
|
||||
<Button variant="outline" onClick={() => setStep(s => s - 1)} className="flex-1 h-11">
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />이전
|
||||
</Button>
|
||||
)}
|
||||
{step < 3 ? (
|
||||
<Button onClick={() => setStep(s => s + 1)} className="flex-1 h-11">다음<ChevronRight className="w-4 h-4 ml-1" /></Button>
|
||||
) : (
|
||||
<Button className="flex-1 h-11">회원가입</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" className="w-full h-11">로그인으로 돌아가기</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 3: 프로그레스 바 스타일
|
||||
function SignupDesign3() {
|
||||
const [step, setStep] = useState(1);
|
||||
const progress = ((step - 1) / 2) * 100;
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<h1 className="text-2xl font-bold">회원가입</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{step === 1 && "기본 정보를 입력해주세요"}
|
||||
{step === 2 && "이메일 인증을 진행해주세요"}
|
||||
{step === 3 && "마지막 단계입니다"}
|
||||
</p>
|
||||
</div>
|
||||
{/* 프로그레스 바 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>단계 {step}/3</span>
|
||||
<span>{Math.round(progress)}% 완료</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary transition-all duration-300" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">회원 유형 *</label>
|
||||
<Select><SelectTrigger className="h-11"><SelectValue placeholder="회원 유형을 선택하세요" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="FARM">농가</SelectItem><SelectItem value="CNSLT">컨설턴트</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">아이디 *</label>
|
||||
<Input placeholder="아이디를 입력하세요" className="h-11" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이름 *</label>
|
||||
<Input placeholder="이름을 입력하세요" className="h-11" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이메일 *</label>
|
||||
<Input type="email" placeholder="이메일을 입력하세요" className="h-11" />
|
||||
</div>
|
||||
<Button variant="outline" className="w-full h-11">인증번호 발송</Button>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">휴대폰 번호 *</label>
|
||||
<Input placeholder="010-0000-0000" className="h-11" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">비밀번호 *</label>
|
||||
<Input type="password" placeholder="비밀번호 (8자 이상)" className="h-11" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex gap-2 pt-2">
|
||||
{step > 1 && (
|
||||
<Button variant="outline" onClick={() => setStep(s => s - 1)} className="flex-1 h-11">이전</Button>
|
||||
)}
|
||||
{step < 3 ? (
|
||||
<Button onClick={() => setStep(s => s + 1)} className="flex-1 h-11">다음</Button>
|
||||
) : (
|
||||
<Button className="flex-1 h-11">회원가입 완료</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<a href="#" className="text-sm text-gray-500 hover:text-primary">이미 계정이 있으신가요? 로그인</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 4: 현재 + 개선 (추천)
|
||||
function SignupDesign4() {
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[600px] lg:grid-cols-2 border rounded-lg overflow-hidden">
|
||||
<div className="bg-white relative hidden lg:flex items-center justify-center">
|
||||
<Image src="/logo-graphic.svg" alt="로고" fill className="object-contain p-16" priority />
|
||||
</div>
|
||||
<div className="flex flex-col p-6 md:p-10 bg-white">
|
||||
<div className="flex flex-1 items-center justify-center lg:justify-start lg:pl-16">
|
||||
<div className="w-full max-w-[320px] lg:max-w-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<h1 className="text-2xl font-bold">회원가입</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{step === 1 && "기본 정보"}
|
||||
{step === 2 && "이메일 인증"}
|
||||
{step === 3 && "추가 정보"}
|
||||
</p>
|
||||
</div>
|
||||
{/* 스텝 인디케이터 */}
|
||||
<div className="flex items-center justify-center gap-2 py-2">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div key={s} className="flex items-center">
|
||||
<div className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors",
|
||||
step === s ? "bg-primary text-white" : step > s ? "bg-primary/20 text-primary" : "bg-gray-100 text-gray-400"
|
||||
)}>
|
||||
{step > s ? <CheckCircle2 className="w-4 h-4" /> : s}
|
||||
</div>
|
||||
{s < 3 && <div className={cn("w-8 h-0.5 mx-1 transition-colors", step > s ? "bg-primary/20" : "bg-gray-200")} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">회원 유형 *</label>
|
||||
<Select><SelectTrigger className="h-11"><SelectValue placeholder="회원 유형을 선택하세요" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="FARM">농가</SelectItem><SelectItem value="CNSLT">컨설턴트</SelectItem><SelectItem value="ORGAN">기관</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">아이디 *</label>
|
||||
<Input placeholder="아이디를 입력하세요 (4자 이상)" className="h-11" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이름 *</label>
|
||||
<Input placeholder="이름을 입력하세요 (2자 이상)" className="h-11" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">이메일 *</label>
|
||||
<div className="flex gap-2">
|
||||
<Input type="text" placeholder="이메일 아이디" className="h-11 flex-1" />
|
||||
<span className="flex items-center text-gray-400">@</span>
|
||||
<Select><SelectTrigger className="h-11 flex-1"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="gmail.com">gmail.com</SelectItem><SelectItem value="naver.com">naver.com</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full h-11">인증번호 발송</Button>
|
||||
<p className="text-xs text-center text-green-600">✓ 이메일 인증이 완료되면 다음 단계로 진행됩니다</p>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">휴대폰 번호 *</label>
|
||||
<Input placeholder="010-0000-0000" className="h-11" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">비밀번호 *</label>
|
||||
<Input type="password" placeholder="비밀번호를 입력하세요 (8자 이상)" className="h-11" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">비밀번호 확인 *</label>
|
||||
<Input type="password" placeholder="비밀번호를 다시 입력하세요" className="h-11" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex gap-2 pt-2">
|
||||
{step > 1 && (
|
||||
<Button variant="outline" onClick={() => setStep(s => s - 1)} className="flex-1 h-11">
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />이전
|
||||
</Button>
|
||||
)}
|
||||
{step < 3 ? (
|
||||
<Button onClick={() => setStep(s => s + 1)} className="flex-1 h-11">다음<ChevronRight className="w-4 h-4 ml-1" /></Button>
|
||||
) : (
|
||||
<Button className="flex-1 h-11">회원가입</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center"><span className="w-full border-t" /></div>
|
||||
<div className="relative flex justify-center text-xs"><span className="bg-white px-2 text-gray-500">또는</span></div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full h-11 border-2 border-primary text-primary">로그인</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SignupDemo() {
|
||||
const designs = [
|
||||
{ id: "current", name: "현재", description: "현재 적용된 3단계 스텝", features: ["숫자 인디케이터"], component: SignupDesign1 },
|
||||
{ id: "label", name: "시안 2", description: "스텝에 라벨 추가", features: ["단계별 라벨", "연결선"], component: SignupDesign2 },
|
||||
{ id: "progress", name: "시안 3", description: "프로그레스 바 스타일", features: ["진행률 바", "퍼센트 표시"], component: SignupDesign3 },
|
||||
{ id: "improved", name: "시안 4", description: "현재 + 개선 (추천)", features: ["h-11 입력필드", "로그인 통일 스타일"], component: SignupDesign4 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">회원가입 페이지 디자인 시안</h1>
|
||||
<p className="text-gray-600 mt-2">각 탭을 클릭하여 다른 디자인 시안을 비교해보세요</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="current" className="space-y-6">
|
||||
<TabsList className="grid grid-cols-4 w-full h-auto p-1">
|
||||
{designs.map((design) => (
|
||||
<TabsTrigger key={design.id} value={design.id} className="py-3 text-xs sm:text-sm data-[state=active]:bg-primary data-[state=active]:text-white">
|
||||
{design.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{designs.map((design) => (
|
||||
<TabsContent key={design.id} value={design.id} className="space-y-4">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{design.name}: {design.description}</h2>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{design.features.map((feature, idx) => (
|
||||
<Badge key={idx} variant="secondary">{feature}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<design.component />
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,485 +0,0 @@
|
||||
'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
@@ -1,331 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
const emailDomains = [
|
||||
'gmail.com',
|
||||
'naver.com',
|
||||
'daum.net',
|
||||
'hanmail.net',
|
||||
'nate.com',
|
||||
'kakao.com',
|
||||
'직접입력',
|
||||
];
|
||||
|
||||
// 시안 1: 직접입력 시 별도 행에 입력창
|
||||
function EmailDomain1() {
|
||||
const [emailId, setEmailId] = useState('');
|
||||
const [emailDomain, setEmailDomain] = useState('');
|
||||
const [customDomain, setCustomDomain] = useState('');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">시안 1: 직접입력 시 별도 행</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder="이메일"
|
||||
value={emailId}
|
||||
onChange={(e) => setEmailId(e.target.value)}
|
||||
className="flex-1 h-11"
|
||||
/>
|
||||
<span className="text-muted-foreground">@</span>
|
||||
<Select value={emailDomain} onValueChange={setEmailDomain}>
|
||||
<SelectTrigger className="flex-1 h-11">
|
||||
<SelectValue placeholder="도메인 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{emailDomains.map((domain) => (
|
||||
<SelectItem key={domain} value={domain}>
|
||||
{domain}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{emailDomain === '직접입력' && (
|
||||
<Input
|
||||
placeholder="도메인을 입력하세요 (예: company.com)"
|
||||
value={customDomain}
|
||||
onChange={(e) => setCustomDomain(e.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
결과: {emailId}@{emailDomain === '직접입력' ? customDomain : emailDomain}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 2: 직접입력 시 드롭다운 자리에 인풋 + 옆에 드롭다운 버튼
|
||||
function EmailDomain2() {
|
||||
const [emailId, setEmailId] = useState('');
|
||||
const [emailDomain, setEmailDomain] = useState('');
|
||||
const [customDomain, setCustomDomain] = useState('');
|
||||
const [isSelectOpen, setIsSelectOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">시안 2: 인풋 + 별도 드롭다운 버튼</h3>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder="이메일"
|
||||
value={emailId}
|
||||
onChange={(e) => setEmailId(e.target.value)}
|
||||
className="flex-1 h-11"
|
||||
/>
|
||||
<span className="text-muted-foreground">@</span>
|
||||
{emailDomain === '직접입력' ? (
|
||||
<div className="flex flex-1 gap-1">
|
||||
<Input
|
||||
placeholder="도메인 입력"
|
||||
value={customDomain}
|
||||
onChange={(e) => setCustomDomain(e.target.value)}
|
||||
className="flex-1 h-11"
|
||||
/>
|
||||
<Select value={emailDomain} onValueChange={(v) => {
|
||||
setEmailDomain(v);
|
||||
if (v !== '직접입력') setCustomDomain('');
|
||||
}}>
|
||||
<SelectTrigger className="w-11 h-11 px-0 justify-center">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{emailDomains.map((domain) => (
|
||||
<SelectItem key={domain} value={domain}>
|
||||
{domain}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<Select value={emailDomain} onValueChange={setEmailDomain}>
|
||||
<SelectTrigger className="flex-1 h-11">
|
||||
<SelectValue placeholder="도메인 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{emailDomains.map((domain) => (
|
||||
<SelectItem key={domain} value={domain}>
|
||||
{domain}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
결과: {emailId}@{emailDomain === '직접입력' ? customDomain : emailDomain}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 3: Combobox 스타일 - 인풋이면서 드롭다운
|
||||
function EmailDomain3() {
|
||||
const [emailId, setEmailId] = useState('');
|
||||
const [emailDomain, setEmailDomain] = useState('');
|
||||
const [customDomain, setCustomDomain] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const displayValue = emailDomain === '직접입력' ? customDomain : emailDomain;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">시안 3: Combobox 스타일 (인풋 + 드롭다운 통합)</h3>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder="이메일"
|
||||
value={emailId}
|
||||
onChange={(e) => setEmailId(e.target.value)}
|
||||
className="flex-1 h-11"
|
||||
/>
|
||||
<span className="text-muted-foreground">@</span>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
placeholder="도메인 선택 또는 입력"
|
||||
value={displayValue}
|
||||
onChange={(e) => {
|
||||
setEmailDomain('직접입력');
|
||||
setCustomDomain(e.target.value);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
className="h-11 pr-10"
|
||||
/>
|
||||
<Select
|
||||
value={emailDomain}
|
||||
onValueChange={(v) => {
|
||||
setEmailDomain(v);
|
||||
if (v !== '직접입력') setCustomDomain('');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<SelectTrigger className="absolute right-0 top-0 w-10 h-11 border-0 bg-transparent hover:bg-transparent focus:ring-0">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{emailDomains.map((domain) => (
|
||||
<SelectItem key={domain} value={domain}>
|
||||
{domain}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
결과: {emailId}@{displayValue}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 4: 드롭다운 영역과 입력 영역 분리
|
||||
function EmailDomain4() {
|
||||
const [emailId, setEmailId] = useState('');
|
||||
const [emailDomain, setEmailDomain] = useState('');
|
||||
const [customDomain, setCustomDomain] = useState('');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">시안 4: 드롭다운 + 직접입력 시 인풋으로 교체 (동일 너비)</h3>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder="이메일"
|
||||
value={emailId}
|
||||
onChange={(e) => setEmailId(e.target.value)}
|
||||
className="w-[140px] h-11"
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0">@</span>
|
||||
<div className="flex-1 flex gap-1">
|
||||
{emailDomain === '직접입력' ? (
|
||||
<Input
|
||||
placeholder="도메인 입력"
|
||||
value={customDomain}
|
||||
onChange={(e) => setCustomDomain(e.target.value)}
|
||||
className="flex-1 h-11"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1" />
|
||||
)}
|
||||
<Select
|
||||
value={emailDomain}
|
||||
onValueChange={(v) => {
|
||||
setEmailDomain(v);
|
||||
if (v !== '직접입력') setCustomDomain('');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={emailDomain === '직접입력' ? "w-[100px] h-11" : "w-full h-11"}>
|
||||
<SelectValue placeholder="도메인 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{emailDomains.map((domain) => (
|
||||
<SelectItem key={domain} value={domain}>
|
||||
{domain}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
결과: {emailId}@{emailDomain === '직접입력' ? customDomain : emailDomain}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시안 5: 인풋과 드롭다운 자연스럽게 통합 (하나의 필드처럼 보이게)
|
||||
function EmailDomain5() {
|
||||
const [emailId, setEmailId] = useState('');
|
||||
const [emailDomain, setEmailDomain] = useState('');
|
||||
const [customDomain, setCustomDomain] = useState('');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">시안 5: 인풋 + 드롭다운 자연스럽게 통합</h3>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder="이메일"
|
||||
value={emailId}
|
||||
onChange={(e) => setEmailId(e.target.value)}
|
||||
className="flex-1 h-11"
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0">@</span>
|
||||
<div className="flex items-center flex-1 h-11 border rounded-md focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
|
||||
{emailDomain === '직접입력' ? (
|
||||
<Input
|
||||
placeholder="도메인 입력"
|
||||
value={customDomain}
|
||||
onChange={(e) => setCustomDomain(e.target.value)}
|
||||
className="flex-1 h-full border-0 focus-visible:ring-0 focus-visible:ring-offset-0 rounded-r-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1 px-3 text-sm truncate">
|
||||
{emailDomain || <span className="text-muted-foreground">도메인 선택</span>}
|
||||
</span>
|
||||
)}
|
||||
<Select
|
||||
value={emailDomain}
|
||||
onValueChange={(v) => {
|
||||
setEmailDomain(v);
|
||||
if (v !== '직접입력') setCustomDomain('');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-10 h-full border-0 bg-transparent px-0 focus:ring-0 rounded-l-none justify-center" />
|
||||
<SelectContent>
|
||||
{emailDomains.map((domain) => (
|
||||
<SelectItem key={domain} value={domain}>
|
||||
{domain}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
결과: {emailId}@{emailDomain === '직접입력' ? customDomain : emailDomain}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EmailDomainDemo() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-8">
|
||||
<div className="max-w-2xl mx-auto space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">이메일 도메인 입력 UI 시안</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
직접입력 선택 시 인풋과 드롭다운이 함께 동작하는 다양한 방식
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg border space-y-8">
|
||||
<EmailDomain1 />
|
||||
<hr />
|
||||
<EmailDomain2 />
|
||||
<hr />
|
||||
<EmailDomain3 />
|
||||
<hr />
|
||||
<EmailDomain4 />
|
||||
<hr />
|
||||
<EmailDomain5 />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,256 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { LayoutDashboard, Database, ChevronRight } from 'lucide-react';
|
||||
|
||||
// 사이드바 색상 조합 데모
|
||||
export default function SidebarColorsDemo() {
|
||||
const colorSchemes = [
|
||||
{
|
||||
name: '현재 스타일 (진한 파란색)',
|
||||
sidebar: 'bg-[#1f3a8f]',
|
||||
header: 'bg-white',
|
||||
activeMenu: 'bg-white text-slate-800',
|
||||
inactiveMenu: 'text-white hover:bg-white/20',
|
||||
description: '강한 대비, 전문적인 느낌'
|
||||
},
|
||||
{
|
||||
name: '밝은 회색 (Notion 스타일)',
|
||||
sidebar: 'bg-slate-100',
|
||||
header: 'bg-white',
|
||||
activeMenu: 'bg-white text-slate-800 shadow-sm',
|
||||
inactiveMenu: 'text-slate-600 hover:bg-slate-200',
|
||||
description: '깔끔하고 모던한 느낌'
|
||||
},
|
||||
{
|
||||
name: '진한 네이비',
|
||||
sidebar: 'bg-slate-900',
|
||||
header: 'bg-white',
|
||||
activeMenu: 'bg-white text-slate-800',
|
||||
inactiveMenu: 'text-slate-300 hover:bg-slate-800',
|
||||
description: '고급스럽고 세련된 느낌'
|
||||
},
|
||||
{
|
||||
name: '연한 파란색',
|
||||
sidebar: 'bg-blue-50',
|
||||
header: 'bg-white',
|
||||
activeMenu: 'bg-white text-blue-900 shadow-sm',
|
||||
inactiveMenu: 'text-blue-800 hover:bg-blue-100',
|
||||
description: '부드럽고 친근한 느낌'
|
||||
},
|
||||
{
|
||||
name: '흰색 + 파란 강조',
|
||||
sidebar: 'bg-white border-r border-slate-200',
|
||||
header: 'bg-white',
|
||||
activeMenu: 'bg-blue-50 text-blue-700 border-l-2 border-blue-600',
|
||||
inactiveMenu: 'text-slate-600 hover:bg-slate-50',
|
||||
description: 'Linear/Vercel 스타일'
|
||||
},
|
||||
{
|
||||
name: '그라데이션 (파란색)',
|
||||
sidebar: 'bg-gradient-to-b from-blue-800 to-blue-900',
|
||||
header: 'bg-white',
|
||||
activeMenu: 'bg-white text-slate-800',
|
||||
inactiveMenu: 'text-blue-100 hover:bg-white/10',
|
||||
description: '현대적이고 세련된 느낌'
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 p-8">
|
||||
<h1 className="text-2xl font-bold text-slate-800 mb-2">사이드바 색상 조합 데모</h1>
|
||||
<p className="text-slate-600 mb-8">로그인 페이지(흰색 배경)와 어울리는 사이드바 스타일 비교</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{colorSchemes.map((scheme, index) => (
|
||||
<div key={index} className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
{/* 미니 레이아웃 프리뷰 */}
|
||||
<div className="h-64 flex">
|
||||
{/* 사이드바 */}
|
||||
<div className={`w-48 flex flex-col ${scheme.sidebar}`}>
|
||||
{/* 헤더 */}
|
||||
<div className={`p-3 ${scheme.header}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-xs font-bold">H</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-slate-800">한우 유전체</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메뉴 */}
|
||||
<div className="flex-1 p-2 space-y-1">
|
||||
{/* 활성 메뉴 */}
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${scheme.activeMenu}`}>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
<span>대시보드</span>
|
||||
</div>
|
||||
|
||||
{/* 비활성 메뉴 */}
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${scheme.inactiveMenu}`}>
|
||||
<Database className="w-4 h-4" />
|
||||
<span>개체 조회</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 영역 */}
|
||||
<div className="flex-1 bg-gradient-to-br from-blue-50/30 via-white to-amber-50/20 p-4">
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center text-slate-400 text-xs">
|
||||
메인 콘텐츠
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="p-4 border-t border-slate-100">
|
||||
<h3 className="font-semibold text-slate-800">{scheme.name}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">{scheme.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 헤더 연결 스타일 비교 */}
|
||||
<h2 className="text-xl font-bold text-slate-800 mt-12 mb-6">헤더 연결 스타일 비교</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 현재: 흰색 헤더 + 파란 사이드바 */}
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="h-72 flex">
|
||||
<div className="w-56 flex flex-col">
|
||||
{/* 흰색 헤더 */}
|
||||
<div className="p-4 bg-white h-16 flex items-center border-b border-slate-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-sm font-bold">H</span>
|
||||
</div>
|
||||
<span className="font-semibold text-slate-800">한우 유전체 분석</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 파란 콘텐츠 */}
|
||||
<div className="flex-1 bg-[#1f3a8f] p-2 space-y-1">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 bg-white rounded-none text-slate-800 text-sm">
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
<span>대시보드</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 text-white text-sm hover:bg-white/20 rounded-md">
|
||||
<Database className="w-4 h-4" />
|
||||
<span>개체 조회</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-gradient-to-br from-blue-50/30 via-white to-amber-50/20"></div>
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-100">
|
||||
<h3 className="font-semibold text-slate-800">현재 스타일</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">흰색 헤더 → 파란 사이드바, 활성 메뉴 흰색</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 대안: 전체 밝은 톤 */}
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="h-72 flex">
|
||||
<div className="w-56 flex flex-col bg-slate-50 border-r border-slate-200">
|
||||
{/* 헤더 */}
|
||||
<div className="p-4 bg-white h-16 flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-sm font-bold">H</span>
|
||||
</div>
|
||||
<span className="font-semibold text-slate-800">한우 유전체 분석</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 */}
|
||||
<div className="flex-1 p-2 space-y-1">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 bg-white text-slate-800 text-sm shadow-sm rounded-md">
|
||||
<LayoutDashboard className="w-4 h-4 text-blue-600" />
|
||||
<span className="font-medium">대시보드</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 text-slate-600 text-sm hover:bg-slate-100 rounded-md">
|
||||
<Database className="w-4 h-4" />
|
||||
<span>개체 조회</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-gradient-to-br from-blue-50/30 via-white to-amber-50/20"></div>
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-100">
|
||||
<h3 className="font-semibold text-slate-800">밝은 톤 스타일</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">전체 밝은 톤, 로그인 페이지와 자연스러운 연결</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 대안: 파란 헤더 + 파란 사이드바 */}
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="h-72 flex">
|
||||
<div className="w-56 flex flex-col bg-[#1f3a8f]">
|
||||
{/* 파란 헤더 */}
|
||||
<div className="p-4 h-16 flex items-center border-b border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-white rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 text-sm font-bold">H</span>
|
||||
</div>
|
||||
<span className="font-semibold text-white">한우 유전체 분석</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 */}
|
||||
<div className="flex-1 p-2 space-y-1">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 bg-white/20 text-white text-sm rounded-md">
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
<span className="font-medium">대시보드</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 text-white/70 text-sm hover:bg-white/10 rounded-md">
|
||||
<Database className="w-4 h-4" />
|
||||
<span>개체 조회</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-gradient-to-br from-blue-50/30 via-white to-amber-50/20"></div>
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-100">
|
||||
<h3 className="font-semibold text-slate-800">전체 파란색 스타일</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">통일된 파란색, 강한 브랜드 아이덴티티</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 대안: 왼쪽 강조선 */}
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="h-72 flex">
|
||||
<div className="w-56 flex flex-col bg-white border-r border-slate-200">
|
||||
{/* 헤더 */}
|
||||
<div className="p-4 h-16 flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-sm font-bold">H</span>
|
||||
</div>
|
||||
<span className="font-semibold text-slate-800">한우 유전체 분석</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 */}
|
||||
<div className="flex-1 p-2 space-y-1">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 bg-blue-50 text-blue-700 text-sm border-l-3 border-blue-600 rounded-r-md">
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
<span className="font-medium">대시보드</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 text-slate-600 text-sm hover:bg-slate-50 rounded-md">
|
||||
<Database className="w-4 h-4" />
|
||||
<span>개체 조회</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-gradient-to-br from-blue-50/30 via-white to-amber-50/20"></div>
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-100">
|
||||
<h3 className="font-semibold text-slate-800">왼쪽 강조선 스타일</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">미니멀하고 깔끔한 네비게이션</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -18,12 +17,10 @@ export default function RootLayout({
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchParams, useRouter } 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 { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { mptApi, MptDto, cowApi } from "@/lib/api"
|
||||
import { CowDetail } from "@/types/cow.types"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Activity,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CowNumberDisplay } from "@/components/common/cow-number-display"
|
||||
import { AuthGuard } from "@/components/auth/auth-guard"
|
||||
|
||||
// 혈액화학검사 항목별 참조값 (정상 범위)
|
||||
const MPT_REFERENCE_VALUES: Record<string, { min: number; max: number; unit: string; name: string }> = {
|
||||
// 에너지 대사
|
||||
glucose: { min: 45, max: 75, unit: 'mg/dL', name: '혈당' },
|
||||
cholesterol: { min: 80, max: 120, unit: 'mg/dL', name: '콜레스테롤' },
|
||||
nefa: { min: 0, max: 0.4, unit: 'mEq/L', name: 'NEFA(유리지방산)' },
|
||||
bcs: { min: 2.5, max: 3.5, unit: '점', name: 'BCS' },
|
||||
// 단백질 대사
|
||||
totalProtein: { min: 6.5, max: 8.5, unit: 'g/dL', name: '총단백질' },
|
||||
albumin: { min: 3.0, max: 3.6, unit: 'g/dL', name: '알부민' },
|
||||
globulin: { min: 3.0, max: 5.0, unit: 'g/dL', name: '글로불린' },
|
||||
agRatio: { min: 0.6, max: 1.2, unit: '', name: 'A/G 비율' },
|
||||
bun: { min: 8, max: 25, unit: 'mg/dL', name: 'BUN(요소태질소)' },
|
||||
// 간기능
|
||||
ast: { min: 45, max: 110, unit: 'U/L', name: 'AST' },
|
||||
ggt: { min: 10, max: 36, unit: 'U/L', name: 'GGT' },
|
||||
fattyLiverIdx: { min: 0, max: 30, unit: '', name: '지방간지수' },
|
||||
// 미네랄
|
||||
calcium: { min: 8.5, max: 11.5, unit: 'mg/dL', name: '칼슘' },
|
||||
phosphorus: { min: 4.0, max: 7.5, unit: 'mg/dL', name: '인' },
|
||||
caPRatio: { min: 1.0, max: 2.0, unit: '', name: 'Ca/P 비율' },
|
||||
magnesium: { min: 1.8, max: 2.5, unit: 'mg/dL', name: '마그네슘' },
|
||||
creatine: { min: 1.0, max: 2.0, unit: 'mg/dL', name: '크레아틴' },
|
||||
}
|
||||
|
||||
// 카테고리별 항목 그룹핑
|
||||
const MPT_CATEGORIES = [
|
||||
{
|
||||
name: '에너지 대사',
|
||||
items: ['glucose', 'cholesterol', 'nefa', 'bcs'],
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
{
|
||||
name: '단백질 대사',
|
||||
items: ['totalProtein', 'albumin', 'globulin', 'agRatio', 'bun'],
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
name: '간기능',
|
||||
items: ['ast', 'ggt', 'fattyLiverIdx'],
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
name: '미네랄',
|
||||
items: ['calcium', 'phosphorus', 'caPRatio', 'magnesium', 'creatine'],
|
||||
color: 'bg-purple-500',
|
||||
},
|
||||
]
|
||||
|
||||
// 측정값 상태 판정 (정상/주의/위험)
|
||||
function getValueStatus(key: string, value: number | null): 'normal' | 'warning' | 'danger' | 'unknown' {
|
||||
if (value === null || value === undefined) return 'unknown'
|
||||
const ref = MPT_REFERENCE_VALUES[key]
|
||||
if (!ref) return 'unknown'
|
||||
|
||||
if (value >= ref.min && value <= ref.max) return 'normal'
|
||||
|
||||
// 10% 이내 범위 이탈은 주의
|
||||
const margin = (ref.max - ref.min) * 0.1
|
||||
if (value >= ref.min - margin && value <= ref.max + margin) return 'warning'
|
||||
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
export default function MptPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const cowShortNo = searchParams.get('cowShortNo')
|
||||
const farmNo = searchParams.get('farmNo')
|
||||
const { toast } = useToast()
|
||||
|
||||
const [searchInput, setSearchInput] = useState(cowShortNo || '')
|
||||
const [mptData, setMptData] = useState<MptDto[]>([])
|
||||
const [selectedMpt, setSelectedMpt] = useState<MptDto | null>(null)
|
||||
const [cow, setCow] = useState<CowDetail | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 검색 실행
|
||||
const handleSearch = async () => {
|
||||
if (!searchInput.trim()) {
|
||||
toast({
|
||||
title: '검색어를 입력해주세요',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await mptApi.findByCowShortNo(searchInput.trim())
|
||||
setMptData(data)
|
||||
if (data.length > 0) {
|
||||
setSelectedMpt(data[0]) // 가장 최근 검사 결과 선택
|
||||
} else {
|
||||
setSelectedMpt(null)
|
||||
toast({
|
||||
title: '검사 결과가 없습니다',
|
||||
description: `개체번호 ${searchInput}의 혈액화학검사 결과를 찾을 수 없습니다.`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MPT 데이터 로드 실패:', error)
|
||||
toast({
|
||||
title: '데이터 로드 실패',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
if (cowShortNo) {
|
||||
handleSearch()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<main className="flex-1 overflow-y-auto bg-white min-h-screen">
|
||||
<div className="w-full p-6 sm:px-6 lg:px-8 sm:py-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted gap-1.5 -ml-2 px-2 sm:px-3"
|
||||
>
|
||||
<ArrowLeft className="h-7 w-7 sm:h-4 sm:w-4" />
|
||||
<span className="hidden sm:inline text-sm">뒤로가기</span>
|
||||
</Button>
|
||||
<div className="w-10 h-10 sm:w-14 sm:h-14 bg-primary rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<Activity className="h-5 w-5 sm:h-7 sm:w-7 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-3xl lg:text-4xl font-bold text-foreground">혈액화학검사</h1>
|
||||
<p className="text-sm sm:text-lg text-muted-foreground">Metabolic Profile Test</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 영역 */}
|
||||
<Card className="bg-white border border-border shadow-sm rounded-2xl">
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
placeholder="개체 요약번호 입력 (예: 4049)"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleSearch} disabled={loading}>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
검색
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!loading && selectedMpt && (
|
||||
<>
|
||||
{/* 개체 정보 */}
|
||||
<h3 className="text-lg lg:text-xl font-bold text-foreground">개체 정보</h3>
|
||||
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="hidden lg:grid lg:grid-cols-4 divide-x divide-border">
|
||||
<div>
|
||||
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||
<span className="text-base font-semibold text-muted-foreground">개체번호</span>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<span className="text-2xl font-bold text-foreground">{selectedMpt.cowShortNo || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||
<span className="text-base font-semibold text-muted-foreground">검사일자</span>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<span className="text-2xl font-bold text-foreground">
|
||||
{selectedMpt.testDt ? new Date(selectedMpt.testDt).toLocaleDateString('ko-KR') : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||
<span className="text-base font-semibold text-muted-foreground">월령</span>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<span className="text-2xl font-bold text-foreground">
|
||||
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-muted/50 px-5 py-3 border-b border-border">
|
||||
<span className="text-base font-semibold text-muted-foreground">산차</span>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<span className="text-2xl font-bold text-foreground">
|
||||
{selectedMpt.parity ? `${selectedMpt.parity}산차` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 모바일 */}
|
||||
<div className="lg:hidden divide-y divide-border">
|
||||
<div className="flex items-center">
|
||||
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">개체번호</span>
|
||||
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">{selectedMpt.cowShortNo || '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">검사일자</span>
|
||||
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
||||
{selectedMpt.testDt ? new Date(selectedMpt.testDt).toLocaleDateString('ko-KR') : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">월령</span>
|
||||
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
||||
{selectedMpt.monthAge ? `${selectedMpt.monthAge}개월` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-28 shrink-0 bg-muted/50 px-4 py-3.5 text-base font-medium text-muted-foreground">산차</span>
|
||||
<span className="flex-1 px-4 py-3.5 text-base font-bold text-foreground">
|
||||
{selectedMpt.parity ? `${selectedMpt.parity}산차` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 혈액화학검사 결과 테이블 */}
|
||||
<h3 className="text-lg lg:text-xl font-bold text-foreground">혈액화학검사 결과</h3>
|
||||
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b border-border">
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-muted-foreground w-28">카테고리</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-muted-foreground">검사항목</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-24">측정값</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20">하한값</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20">상한값</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20">단위</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground w-20">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MPT_CATEGORIES.map((category, catIdx) => (
|
||||
category.items.map((itemKey, itemIdx) => {
|
||||
const ref = MPT_REFERENCE_VALUES[itemKey]
|
||||
const value = selectedMpt[itemKey as keyof MptDto] as number | null
|
||||
const status = getValueStatus(itemKey, value)
|
||||
|
||||
return (
|
||||
<tr key={itemKey} className="border-b border-border hover:bg-muted/30">
|
||||
{itemIdx === 0 && (
|
||||
<td
|
||||
rowSpan={category.items.length}
|
||||
className={`px-4 py-3 text-sm font-semibold text-white ${category.color} align-middle text-center`}
|
||||
>
|
||||
{category.name}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3 text-sm font-medium text-foreground">{ref?.name || itemKey}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`text-lg font-bold ${
|
||||
status === 'normal' ? 'text-green-600' :
|
||||
status === 'warning' ? 'text-amber-600' :
|
||||
status === 'danger' ? 'text-red-600' :
|
||||
'text-muted-foreground'
|
||||
}`}>
|
||||
{value !== null && value !== undefined ? value.toFixed(2) : '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.min ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.max ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.unit || '-'}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold ${
|
||||
status === 'normal' ? 'bg-green-100 text-green-700' :
|
||||
status === 'warning' ? 'bg-amber-100 text-amber-700' :
|
||||
status === 'danger' ? 'bg-red-100 text-red-700' :
|
||||
'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{status === 'normal' ? '정상' :
|
||||
status === 'warning' ? '주의' :
|
||||
status === 'danger' ? '이상' : '-'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 검사 이력 (여러 검사 결과가 있을 경우) */}
|
||||
{mptData.length > 1 && (
|
||||
<>
|
||||
<h3 className="text-lg lg:text-xl font-bold text-foreground">검사 이력</h3>
|
||||
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mptData.map((mpt, idx) => (
|
||||
<Button
|
||||
key={mpt.pkMptNo}
|
||||
variant={selectedMpt?.pkMptNo === mpt.pkMptNo ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedMpt(mpt)}
|
||||
>
|
||||
{mpt.testDt ? new Date(mpt.testDt).toLocaleDateString('ko-KR') : `검사 ${idx + 1}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !selectedMpt && cowShortNo && (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<Activity className="h-16 w-16 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-lg font-medium text-muted-foreground">검사 결과가 없습니다</p>
|
||||
<p className="text-sm text-muted-foreground">해당 개체의 혈액화학검사 결과를 찾을 수 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !selectedMpt && !cowShortNo && (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<Search className="h-16 w-16 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-lg font-medium text-muted-foreground">개체번호를 검색해주세요</p>
|
||||
<p className="text-sm text-muted-foreground">개체 요약번호를 입력하여 혈액화학검사 결과를 조회합니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export default function Home() {
|
||||
if (isAuthenticated && accessToken) {
|
||||
try {
|
||||
// 프로필 로드로 토큰 유효성 확인
|
||||
// await loadProfile(); // 👈 주석처리: 백엔드 /users/profile 미구현으로 인한 401 에러 방지
|
||||
// await loadProfile(); // 백엔드 /users/profile 미구현으로 인한 401 에러 방지
|
||||
// 성공하면 대시보드로
|
||||
router.push("/dashboard");
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,9 +9,10 @@ import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||
import { Settings2, Filter, X, Search, ChevronDown, ChevronUp, Loader2, Plus, Pin, GripVertical } from "lucide-react"
|
||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
|
||||
import { useFilterStore } from "@/store/filter-store"
|
||||
import { DEFAULT_FILTER_SETTINGS } from "@/types/filter.types"
|
||||
import { geneApi, type MarkerModel } from "@/lib/api/gene.api"
|
||||
import { TRAIT_CATEGORY_LIST as TRAIT_CATEGORIES, TRAIT_DESCRIPTIONS } from "@/constants/traits"
|
||||
import { geneApi } from "@/lib/api/gene.api"
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -173,54 +174,6 @@ function SortableTraitItem({
|
||||
)
|
||||
}
|
||||
|
||||
// 형질 카테고리 정의
|
||||
const TRAIT_CATEGORIES = [
|
||||
{ id: 'growth', name: '성장형질', traits: ['12개월령체중'] },
|
||||
{ id: 'economic', name: '경제형질', traits: ['도체중', '등심단면적', '등지방두께', '근내지방도'] },
|
||||
{ id: 'body', name: '체형형질', traits: ['체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위'] },
|
||||
{ id: 'weight', name: '부위별무게', traits: ['안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight', '우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight'] },
|
||||
{ id: 'rate', name: '부위별비율', traits: ['안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate', '우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate'] },
|
||||
]
|
||||
|
||||
// 형질 설명
|
||||
const TRAIT_DESCRIPTIONS: 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': '전체 대비 갈비 비율',
|
||||
}
|
||||
|
||||
// 형질 표시 이름 (DB 키 -> 화면 표시용)
|
||||
const TRAIT_DISPLAY_NAMES: Record<string, string> = {
|
||||
'안심weight': '안심중량',
|
||||
@@ -255,7 +208,7 @@ interface GlobalFilterDialogProps {
|
||||
}
|
||||
|
||||
export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCount = 0, traitCount = 0 }: GlobalFilterDialogProps = {}) {
|
||||
const { filters, updateFilters, resetFilters } = useGlobalFilter()
|
||||
const { filters, updateFilters, resetFilters } = useFilterStore()
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
|
||||
const open = externalOpen !== undefined ? externalOpen : internalOpen
|
||||
@@ -271,8 +224,8 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCou
|
||||
const [openCategories, setOpenCategories] = useState<Record<string, boolean>>({})
|
||||
|
||||
// DB에서 가져온 유전자 목록
|
||||
const [quantityGenes, setQuantityGenes] = useState<MarkerModel[]>([])
|
||||
const [qualityGenes, setQualityGenes] = useState<MarkerModel[]>([])
|
||||
const [quantityGenes, setQuantityGenes] = useState<any[]>([])
|
||||
const [qualityGenes, setQualityGenes] = useState<any[]>([])
|
||||
const [loadingGenes, setLoadingGenes] = useState(false)
|
||||
|
||||
// 로컬 상태
|
||||
@@ -434,7 +387,7 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCou
|
||||
}
|
||||
|
||||
// 카테고리 전체 선택/해제
|
||||
const toggleCategoryGenes = (genes: MarkerModel[], select: boolean) => {
|
||||
const toggleCategoryGenes = (genes: any[], select: boolean) => {
|
||||
setLocalFilters(prev => {
|
||||
if (select) {
|
||||
const newGenes = [...prev.selectedGenes]
|
||||
@@ -938,14 +891,14 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCou
|
||||
<div className="space-y-2">
|
||||
{TRAIT_CATEGORIES.map(cat => {
|
||||
const isCategoryMatch = traitSearch && cat.name.toLowerCase().includes(traitSearch.toLowerCase())
|
||||
const filteredTraits = traitSearch
|
||||
const filteredTraits: string[] = traitSearch
|
||||
? isCategoryMatch
|
||||
? cat.traits
|
||||
: cat.traits.filter(t =>
|
||||
? [...cat.traits]
|
||||
: [...cat.traits].filter(t =>
|
||||
t.toLowerCase().includes(traitSearch.toLowerCase()) ||
|
||||
(TRAIT_DESCRIPTIONS[t] && TRAIT_DESCRIPTIONS[t].toLowerCase().includes(traitSearch.toLowerCase()))
|
||||
)
|
||||
: cat.traits
|
||||
: [...cat.traits]
|
||||
|
||||
if (traitSearch && filteredTraits.length === 0) return null
|
||||
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
'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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
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 { useFilterStore } from "@/store/filter-store"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Filter, ChevronDown, ChevronUp } from "lucide-react"
|
||||
@@ -21,7 +21,7 @@ interface GenePossessionStatusProps {
|
||||
|
||||
export function GenePossessionStatus({ farmNo }: GenePossessionStatusProps) {
|
||||
const { selectedYear } = useAnalysisYear()
|
||||
const { filters } = useGlobalFilter()
|
||||
const { filters } = useFilterStore()
|
||||
const [allGenes, setAllGenes] = useState<GeneData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { geneApi } from "@/lib/api/gene.api"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
interface GeneSearchDrawerProps {
|
||||
@@ -18,7 +18,7 @@ interface GeneSearchDrawerProps {
|
||||
}
|
||||
|
||||
export function GeneSearchModal({ open, onOpenChange, selectedGenes, onGenesChange }: GeneSearchDrawerProps) {
|
||||
const [allGenes, setAllGenes] = useState<MarkerModel[]>([])
|
||||
const [allGenes, setAllGenes] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [filterType, setFilterType] = useState<'ALL' | 'QTY' | 'QLT'>('ALL')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { PieChart as PieChartIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import apiClient from "@/lib/api-client"
|
||||
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts'
|
||||
|
||||
interface DistributionData {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Target } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import apiClient from "@/lib/api-client"
|
||||
import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Tooltip } from 'recharts'
|
||||
|
||||
interface TraitScore {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ArrowUpRight, ArrowDownRight, TrendingUp, TrendingDown } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import apiClient from "@/lib/api-client"
|
||||
|
||||
interface GenomeData {
|
||||
trait: string
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import apiClient from "@/lib/api-client"
|
||||
|
||||
interface TraitData {
|
||||
trait: string
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { useAnalysisYear } from "@/contexts/AnalysisYearContext";
|
||||
import { useGlobalFilter } from "@/contexts/GlobalFilterContext";
|
||||
import { useFilterStore } from "@/store/filter-store";
|
||||
import { useAuthStore } from "@/store/auth-store";
|
||||
import { LogOut, User } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -28,7 +28,7 @@ import { useRouter } from "next/navigation";
|
||||
export function SiteHeader() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { filters } = useGlobalFilter();
|
||||
const { filters } = useFilterStore();
|
||||
const { selectedYear, setSelectedYear, availableYears } = useAnalysisYear();
|
||||
|
||||
const handleLogout = async () => {
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
/**
|
||||
* MPT (혈액대사판정시험) 항목별 권장치 참고 범위
|
||||
* MPT 참조값의 중앙 관리 파일
|
||||
*/
|
||||
|
||||
export interface MptReferenceRange {
|
||||
name: string; // 한글 표시명
|
||||
upperLimit: number | null;
|
||||
lowerLimit: number | null;
|
||||
unit: string;
|
||||
category: '에너지' | '단백질' | '간기능' | '미네랄' | '별도';
|
||||
description?: string; // 항목 설명 (선택)
|
||||
}
|
||||
|
||||
export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
|
||||
// 에너지 카테고리
|
||||
glucose: {
|
||||
name: '혈당',
|
||||
upperLimit: 84,
|
||||
lowerLimit: 40,
|
||||
unit: 'mg/dL',
|
||||
category: '에너지',
|
||||
description: '에너지 대사 상태 지표',
|
||||
},
|
||||
cholesterol: {
|
||||
name: '콜레스테롤',
|
||||
upperLimit: 252,
|
||||
lowerLimit: 74,
|
||||
unit: 'mg/dL',
|
||||
category: '에너지',
|
||||
description: '혈액 내 콜레스테롤 수치',
|
||||
},
|
||||
nefa: {
|
||||
name: '유리지방산(NEFA)',
|
||||
upperLimit: 660,
|
||||
lowerLimit: 115,
|
||||
unit: 'μEq/L',
|
||||
category: '에너지',
|
||||
description: '혈액 내 유리지방산 수치',
|
||||
},
|
||||
bcs: {
|
||||
name: 'BCS',
|
||||
upperLimit: 3.5,
|
||||
lowerLimit: 2.5,
|
||||
unit: '-',
|
||||
category: '에너지',
|
||||
description: '체충실지수(Body Condition Score)',
|
||||
},
|
||||
|
||||
// 단백질 카테고리
|
||||
totalProtein: {
|
||||
name: '총단백질',
|
||||
upperLimit: 7.7,
|
||||
lowerLimit: 6.2,
|
||||
unit: 'g/dL',
|
||||
category: '단백질',
|
||||
description: '혈액 내 총단백질 수치',
|
||||
},
|
||||
albumin: {
|
||||
name: '알부민',
|
||||
upperLimit: 4.3,
|
||||
lowerLimit: 3.3,
|
||||
unit: 'g/dL',
|
||||
category: '단백질',
|
||||
description: '혈액 내 알부민 수치',
|
||||
},
|
||||
globulin: {
|
||||
name: '총글로불린',
|
||||
upperLimit: 36.1,
|
||||
lowerLimit: 9.1,
|
||||
unit: 'g/dL',
|
||||
category: '단백질',
|
||||
description: '혈액 내 총글로불린 수치',
|
||||
},
|
||||
agRatio: {
|
||||
name: 'A/G ',
|
||||
upperLimit: 0.4,
|
||||
lowerLimit: 0.1,
|
||||
unit: '-',
|
||||
category: '단백질',
|
||||
description: '혈액 내 A/G 수치',
|
||||
},
|
||||
bun: {
|
||||
name: '요소태질소(BUN)',
|
||||
upperLimit: 18.9,
|
||||
lowerLimit: 11.7,
|
||||
unit: 'mg/dL',
|
||||
category: '단백질',
|
||||
description: '혈액 내 요소태질소 수치',
|
||||
},
|
||||
|
||||
// 간기능 카테고리
|
||||
ast: {
|
||||
name: 'AST',
|
||||
upperLimit: 92,
|
||||
lowerLimit: 47,
|
||||
unit: 'U/L',
|
||||
category: '간기능',
|
||||
description: '혈액 내 AST 수치',
|
||||
},
|
||||
ggt: {
|
||||
name: 'GGT',
|
||||
upperLimit: 32,
|
||||
lowerLimit: 11,
|
||||
unit: 'U/L',
|
||||
category: '간기능',
|
||||
description: '혈액 내 GGT 수치',
|
||||
},
|
||||
fattyLiverIdx: {
|
||||
name: '지방간 지수',
|
||||
upperLimit: 9.9,
|
||||
lowerLimit: -1.2,
|
||||
unit: '-',
|
||||
category: '간기능',
|
||||
description: '혈액 내 지방간 지수 수치',
|
||||
},
|
||||
|
||||
// 미네랄 카테고리
|
||||
calcium: {
|
||||
name: '칼슘',
|
||||
upperLimit: 10.6,
|
||||
lowerLimit: 8.1,
|
||||
unit: 'mg/dL',
|
||||
category: '미네랄',
|
||||
description: '혈액 내 칼슘 수치',
|
||||
},
|
||||
phosphorus: {
|
||||
name: '인',
|
||||
upperLimit: 8.9,
|
||||
lowerLimit: 6.2,
|
||||
unit: 'mg/dL',
|
||||
category: '미네랄',
|
||||
description: '혈액 내 인 수치',
|
||||
},
|
||||
caPRatio: {
|
||||
name: '칼슘/인 비율',
|
||||
upperLimit: 1.3,
|
||||
lowerLimit: 1.2,
|
||||
unit: '-',
|
||||
category: '미네랄',
|
||||
description: '혈액 내 칼슘/인 비율 수치',
|
||||
},
|
||||
magnesium: {
|
||||
name: '마그네슘',
|
||||
upperLimit: 3.3,
|
||||
lowerLimit: 1.6,
|
||||
unit: 'mg/dL',
|
||||
category: '미네랄',
|
||||
description: '혈액 내 마그네슘 수치',
|
||||
},
|
||||
|
||||
// 별도 카테고리
|
||||
creatine: {
|
||||
name: '크레아틴',
|
||||
upperLimit: 1.3,
|
||||
lowerLimit: 1.0,
|
||||
unit: 'mg/dL',
|
||||
category: '별도',
|
||||
description: '혈액 내 크레아틴 수치',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* MPT 카테고리 목록 (표시 순서)
|
||||
*/
|
||||
export const MPT_CATEGORIES = ['에너지', '단백질', '간기능', '미네랄', '별도'] as const;
|
||||
|
||||
/**
|
||||
* 측정값이 정상 범위 내에 있는지 확인
|
||||
*/
|
||||
export function isWithinRange(
|
||||
value: number,
|
||||
itemKey: string
|
||||
): 'normal' | 'high' | 'low' | 'unknown' {
|
||||
const reference = MPT_REFERENCE_RANGES[itemKey];
|
||||
|
||||
if (!reference || reference.upperLimit === null || reference.lowerLimit === null) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if (value > reference.upperLimit) return 'high';
|
||||
if (value < reference.lowerLimit) return 'low';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별로 MPT 항목 그룹화
|
||||
*/
|
||||
export function getMptItemsByCategory() {
|
||||
const grouped: Record<string, string[]> = {};
|
||||
|
||||
MPT_CATEGORIES.forEach((category) => {
|
||||
grouped[category] = [];
|
||||
});
|
||||
|
||||
Object.entries(MPT_REFERENCE_RANGES).forEach(([itemKey, reference]) => {
|
||||
grouped[reference.category].push(itemKey);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
180
frontend/src/constants/traits.ts
Normal file
180
frontend/src/constants/traits.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 형질(Trait) 관련 상수 정의
|
||||
*
|
||||
* @description
|
||||
* 유전체 분석에서 사용하는 35개 형질 목록
|
||||
* 백엔드 TraitTypes.ts와 동기화 필요
|
||||
*/
|
||||
|
||||
/** 성장형질 (1개) */
|
||||
export const GROWTH_TRAITS = ['12개월령체중'] as const;
|
||||
|
||||
/** 경제형질 (4개) - 생산 카테고리 */
|
||||
export const ECONOMIC_TRAITS = ['도체중', '등심단면적', '등지방두께', '근내지방도'] as const;
|
||||
|
||||
/** 체형형질 (10개) */
|
||||
export const BODY_TRAITS = [
|
||||
'체고', '십자', '체장', '흉심', '흉폭',
|
||||
'고장', '요각폭', '좌골폭', '곤폭', '흉위',
|
||||
] as const;
|
||||
|
||||
/** 부위별 무게 (10개) */
|
||||
export const WEIGHT_TRAITS = [
|
||||
'안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight',
|
||||
'우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight',
|
||||
] as const;
|
||||
|
||||
/** 부위별 비율 (10개) */
|
||||
export const RATE_TRAITS = [
|
||||
'안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate',
|
||||
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
||||
] as const;
|
||||
|
||||
/** 전체 형질 (35개) */
|
||||
export const ALL_TRAITS = [
|
||||
...GROWTH_TRAITS,
|
||||
...ECONOMIC_TRAITS,
|
||||
...BODY_TRAITS,
|
||||
...WEIGHT_TRAITS,
|
||||
...RATE_TRAITS,
|
||||
] as const;
|
||||
|
||||
/** 낮을수록 좋은 형질 (부호 반전 필요) */
|
||||
export const NEGATIVE_TRAITS: string[] = ['등지방두께'];
|
||||
|
||||
/** 기본 선택 형질 (7개) */
|
||||
export const DEFAULT_TRAITS = ['도체중', '등심단면적', '등지방두께', '근내지방도', '체장', '체고', '등심weight'] as const;
|
||||
|
||||
/** 형질 타입 */
|
||||
export type TraitName = typeof ALL_TRAITS[number];
|
||||
|
||||
/** 카테고리 타입 */
|
||||
export type TraitCategory = '성장' | '생산' | '체형' | '무게' | '비율';
|
||||
|
||||
/**
|
||||
* 카테고리별 형질 목록
|
||||
*/
|
||||
export const TRAIT_CATEGORIES: Record<TraitCategory, readonly string[]> = {
|
||||
'성장': GROWTH_TRAITS,
|
||||
'생산': ECONOMIC_TRAITS,
|
||||
'체형': BODY_TRAITS,
|
||||
'무게': WEIGHT_TRAITS,
|
||||
'비율': RATE_TRAITS,
|
||||
};
|
||||
|
||||
/**
|
||||
* UI용 카테고리 정보 (id, name, traits)
|
||||
*/
|
||||
export const TRAIT_CATEGORY_LIST = [
|
||||
{ id: 'growth', name: '성장형질', traits: [...GROWTH_TRAITS] },
|
||||
{ id: 'economic', name: '경제형질', traits: [...ECONOMIC_TRAITS] },
|
||||
{ id: 'body', name: '체형형질', traits: [...BODY_TRAITS] },
|
||||
{ id: 'weight', name: '부위별무게', traits: [...WEIGHT_TRAITS] },
|
||||
{ id: 'rate', name: '부위별비율', traits: [...RATE_TRAITS] },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 형질별 카테고리 매핑
|
||||
*/
|
||||
export const TRAIT_CATEGORY_MAP: Record<string, TraitCategory> = {
|
||||
// 성장
|
||||
'12개월령체중': '성장',
|
||||
// 생산
|
||||
'도체중': '생산',
|
||||
'등심단면적': '생산',
|
||||
'등지방두께': '생산',
|
||||
'근내지방도': '생산',
|
||||
// 체형
|
||||
'체고': '체형',
|
||||
'십자': '체형',
|
||||
'체장': '체형',
|
||||
'흉심': '체형',
|
||||
'흉폭': '체형',
|
||||
'고장': '체형',
|
||||
'요각폭': '체형',
|
||||
'좌골폭': '체형',
|
||||
'곤폭': '체형',
|
||||
'흉위': '체형',
|
||||
// 무게
|
||||
'안심weight': '무게',
|
||||
'등심weight': '무게',
|
||||
'채끝weight': '무게',
|
||||
'목심weight': '무게',
|
||||
'앞다리weight': '무게',
|
||||
'우둔weight': '무게',
|
||||
'설도weight': '무게',
|
||||
'사태weight': '무게',
|
||||
'양지weight': '무게',
|
||||
'갈비weight': '무게',
|
||||
// 비율
|
||||
'안심rate': '비율',
|
||||
'등심rate': '비율',
|
||||
'채끝rate': '비율',
|
||||
'목심rate': '비율',
|
||||
'앞다리rate': '비율',
|
||||
'우둔rate': '비율',
|
||||
'설도rate': '비율',
|
||||
'사태rate': '비율',
|
||||
'양지rate': '비율',
|
||||
'갈비rate': '비율',
|
||||
};
|
||||
|
||||
/**
|
||||
* 형질 설명 (툴팁용)
|
||||
*/
|
||||
export const TRAIT_DESCRIPTIONS: 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': '전체 대비 갈비 비율',
|
||||
};
|
||||
|
||||
/**
|
||||
* 형질명으로 카테고리 조회
|
||||
*/
|
||||
export function getTraitCategory(traitName: string): TraitCategory | '기타' {
|
||||
return TRAIT_CATEGORY_MAP[traitName] ?? '기타';
|
||||
}
|
||||
|
||||
/**
|
||||
* 형질명으로 설명 조회
|
||||
*/
|
||||
export function getTraitDescription(traitName: string): string {
|
||||
return TRAIT_DESCRIPTIONS[traitName] ?? traitName;
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnalysisYearContext - 분석 연도 선택 Context
|
||||
*
|
||||
* 기능:
|
||||
* - 현재 연도부터 5년 전까지 선택 가능 (예: 2025~2020)
|
||||
* - URL 파라미터 ?year=2024 와 동기화
|
||||
*
|
||||
* 사용처:
|
||||
* - site-header.tsx: 헤더 연도 선택 드롭다운
|
||||
* - genome-integrated-comparison.tsx: 선택된 연도로 데이터 조회
|
||||
* - gene-possession-status.tsx: 선택된 연도로 데이터 조회
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, ReactNode } from 'react'
|
||||
import { useFilterStore } from '@/store/filter-store'
|
||||
import { GlobalFilterSettings } from '@/types/filter.types'
|
||||
|
||||
/**
|
||||
* GlobalFilterContext - Zustand store 래퍼
|
||||
* 기존 코드 호환성을 위해 Context API 인터페이스 유지
|
||||
*/
|
||||
interface GlobalFilterContextType {
|
||||
filters: GlobalFilterSettings
|
||||
updateFilters: (newFilters: Partial<GlobalFilterSettings>) => void
|
||||
resetFilters: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const GlobalFilterContext = createContext<GlobalFilterContextType | undefined>(undefined)
|
||||
|
||||
export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
const { filters, updateFilters, resetFilters, isLoading } = useFilterStore()
|
||||
|
||||
return (
|
||||
<GlobalFilterContext.Provider value={{ filters, updateFilters, resetFilters, isLoading }}>
|
||||
{children}
|
||||
</GlobalFilterContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useGlobalFilter() {
|
||||
const context = useContext(GlobalFilterContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useGlobalFilter must be used within a GlobalFilterProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* useMediaQuery - CSS 미디어 쿼리 상태 감지 훅
|
||||
*
|
||||
* 사용처:
|
||||
* - cow/[cowNo]/page.tsx: 반응형 레이아웃 처리
|
||||
* - category-evaluation-card.tsx: 반응형 UI 처리
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
export function useMediaQuery(query: string) {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* useIsMobile - 모바일 화면 감지 훅 (768px 미만)
|
||||
*
|
||||
* 사용처:
|
||||
* - sidebar.tsx: 모바일에서 사이드바 동작 변경
|
||||
*/
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* useToast - 토스트 알림 훅 (shadcn/ui)
|
||||
*
|
||||
* 사용처:
|
||||
* - cow/[cowNo]/page.tsx: 에러/성공 알림
|
||||
* - reproduction/page.tsx: 데이터 로드 실패 알림
|
||||
* - mpt/page.tsx: 검색 결과 알림
|
||||
* - toaster.tsx: 토스트 렌더링
|
||||
*/
|
||||
import * as React from "react"
|
||||
|
||||
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
LoginDto,
|
||||
AuthResponseDto,
|
||||
UserProfileDto,
|
||||
UpdateProfileDto,
|
||||
} from '@/types/auth.types';
|
||||
|
||||
/**
|
||||
@@ -66,21 +65,6 @@ export const authApi = {
|
||||
return await apiClient.get('/users/profile');
|
||||
},
|
||||
|
||||
/**
|
||||
* 프로필 수정 : 미구현
|
||||
*/
|
||||
updateProfile: async (dto: UpdateProfileDto): Promise<UserProfileDto> => {
|
||||
// 인터셉터가 자동으로 언래핑
|
||||
return await apiClient.patch('/users/profile', dto);
|
||||
},
|
||||
|
||||
/**
|
||||
* 토큰 갱신
|
||||
*/
|
||||
refreshToken: async (refreshToken: string): Promise<AuthResponseDto> => {
|
||||
// 인터셉터가 자동으로 언래핑
|
||||
return await apiClient.post('/auth/refresh', { refreshToken });
|
||||
},
|
||||
|
||||
/**
|
||||
* 비밀번호 변경
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import apiClient from '../api-client';
|
||||
|
||||
/**
|
||||
* 교배 조합 저장 관련 API
|
||||
*/
|
||||
|
||||
export interface BreedSave {
|
||||
pkSaveNo: number;
|
||||
fkUserNo: number;
|
||||
fkCowNo: string;
|
||||
fkKpnNo: string;
|
||||
saveMemo?: string;
|
||||
delYn: 'Y' | 'N';
|
||||
regDt: Date;
|
||||
updtDt: Date;
|
||||
scheduledDate?: string; // 교배 예정일 (선택)
|
||||
completed?: boolean; // 교배 완료 여부 (선택)
|
||||
completedDate?: string; // 교배 완료일 (선택)
|
||||
cow?: any;
|
||||
kpn?: any;
|
||||
user?: any;
|
||||
}
|
||||
|
||||
export interface CreateBreedSaveDto {
|
||||
fkUserNo: number;
|
||||
fkCowNo: string;
|
||||
fkKpnNo: string;
|
||||
saveMemo?: string;
|
||||
scheduledDate?: string;
|
||||
}
|
||||
|
||||
export interface UpdateBreedSaveDto {
|
||||
saveMemo?: string;
|
||||
scheduledDate?: string;
|
||||
completed?: boolean;
|
||||
completedDate?: string;
|
||||
}
|
||||
|
||||
export interface FilterBreedSaveDto {
|
||||
fkUserNo?: number;
|
||||
fkCowNo?: string;
|
||||
fkKpnNo?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
export const breedApi = {
|
||||
/**
|
||||
* POST /breed - 교배 조합 저장
|
||||
*/
|
||||
create: async (data: CreateBreedSaveDto): Promise<BreedSave> => {
|
||||
return await apiClient.post('/breed', data) as unknown as BreedSave;
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /breed - 교배 조합 목록 조회 (필터링 + 페이징)
|
||||
*/
|
||||
findAll: async (filter?: FilterBreedSaveDto): Promise<{
|
||||
data: BreedSave[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}> => {
|
||||
return await apiClient.get('/breed', { params: filter });
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /breed/search - 교배 조합 검색
|
||||
*
|
||||
* @param keyword - 검색어 (개체번호, KPN번호, 메모)
|
||||
* @param userNo - 사용자 번호 (선택)
|
||||
* @param limit - 결과 제한 (기본 20)
|
||||
*/
|
||||
search: async (keyword: string, userNo?: number, limit: number = 20): Promise<BreedSave[]> => {
|
||||
return await apiClient.get('/breed/search', {
|
||||
params: { keyword, userNo, limit },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /breed/:id - 교배 조합 단건 조회
|
||||
*/
|
||||
findOne: async (id: number): Promise<BreedSave> => {
|
||||
return await apiClient.get(`/breed/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /breed/cow/:cowNo - 암소별 교배 조합 조회
|
||||
*/
|
||||
findByCow: async (cowNo: string): Promise<BreedSave[]> => {
|
||||
return await apiClient.get(`/breed/cow/${cowNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /breed/kpn/:kpnNo - KPN별 교배 조합 조회
|
||||
*/
|
||||
findByKpn: async (kpnNo: string): Promise<BreedSave[]> => {
|
||||
return await apiClient.get(`/breed/kpn/${kpnNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /breed/user/:userNo - 사용자별 교배 조합 조회
|
||||
*/
|
||||
findByUser: async (userNo: number): Promise<BreedSave[]> => {
|
||||
return await apiClient.get(`/breed/user/${userNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /breed/date-range/:startDate/:endDate - 날짜 범위로 조회
|
||||
*/
|
||||
findByDateRange: async (startDate: string, endDate: string): Promise<BreedSave[]> => {
|
||||
return await apiClient.get(`/breed/date-range/${startDate}/${endDate}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /breed/:id - 교배 조합 수정
|
||||
*/
|
||||
update: async (id: number, data: UpdateBreedSaveDto): Promise<BreedSave> => {
|
||||
return await apiClient.patch(`/breed/${id}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /breed/:id - 교배 조합 삭제 (소프트 삭제)
|
||||
*/
|
||||
remove: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`/breed/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -1,50 +1,11 @@
|
||||
import apiClient from "../api-client";
|
||||
import { RankingRequest } from "@/types/ranking.types";
|
||||
import { CowDto, CowDetailResponseDto } from "@/types/cow.types";
|
||||
import { CowDetailResponseDto } from "@/types/cow.types";
|
||||
|
||||
/**
|
||||
* 개체(Cow) 관련 API
|
||||
*/
|
||||
export const cowApi = {
|
||||
/**
|
||||
* GET /cow - 전체 개체 목록
|
||||
*/
|
||||
findAll: async (): Promise<CowDto[]> => {
|
||||
return await apiClient.get("/cow");
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /cow/paginated - 페이지네이션 목록
|
||||
*/
|
||||
findAllWithPagination: async (
|
||||
page: number = 1,
|
||||
limit: number = 10
|
||||
): Promise<{ data: CowDto[]; total: number; page: number; limit: number }> => {
|
||||
return await apiClient.get("/cow/paginated", {
|
||||
params: { page, limit },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /cow/farm/:farmNo - 특정 농장의 개체 목록
|
||||
*/
|
||||
findByFarmNo: async (farmNo: number): Promise<CowDto[]> => {
|
||||
return await apiClient.get(`/cow/farm/${farmNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /cow/search - 개체 검색 (개체번호 또는 개체명)
|
||||
*/
|
||||
search: async (
|
||||
keyword: string,
|
||||
farmNo?: number,
|
||||
limit: number = 20
|
||||
): Promise<CowDto[]> => {
|
||||
return await apiClient.get("/cow/search", {
|
||||
params: { keyword, farmNo, limit },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /cow/:cowNo - 개체 상세 조회
|
||||
*/
|
||||
@@ -58,12 +19,4 @@ export const cowApi = {
|
||||
getRanking: async (rankingRequest: RankingRequest): Promise<any> => {
|
||||
return await apiClient.post("/cow/ranking", rankingRequest);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /cow/ranking/global - 전체 농가 개체 대상 랭킹 조회
|
||||
*/
|
||||
getGlobalRanking: async (rankingRequest: RankingRequest): Promise<any> => {
|
||||
return await apiClient.post("/cow/ranking/global", rankingRequest);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import apiClient from '../api-client';
|
||||
|
||||
/**
|
||||
* 대시보드 필터 DTO
|
||||
*/
|
||||
export interface DashboardFilterDto {
|
||||
anlysStatus?: string;
|
||||
reproType?: string;
|
||||
geneGrades?: string[];
|
||||
genomeGrades?: string[];
|
||||
reproGrades?: string[];
|
||||
targetGenes?: string[];
|
||||
minScore?: number;
|
||||
limit?: number; // 결과 개수 제한 (예: Top 3, Top 5)
|
||||
regionNm?: string; // 지역명 (예: 보은군)
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 소 순위 정보
|
||||
*/
|
||||
export interface CattleRankingDto {
|
||||
cowNo: string; // 개체 번호
|
||||
cowName: string; // 개체 이름
|
||||
genomeScore: number; // 유전체 점수
|
||||
rank: number; // 보은군 내 순위
|
||||
totalCattle: number; // 보은군 전체 소 수
|
||||
percentile: number; // 상위 몇 %
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장 내 소 순위 목록
|
||||
*/
|
||||
export interface FarmCattleRankingsDto {
|
||||
farmNo: number;
|
||||
farmName: string;
|
||||
regionName: string;
|
||||
totalCattle: number; // 보은군 전체 소 수
|
||||
farmCattleCount: number; // 농장 내 소 수
|
||||
rankings: CattleRankingDto[];
|
||||
statistics: {
|
||||
bestRank: number; // 최고 순위
|
||||
averageRank: number; // 평균 순위
|
||||
topPercentCount: number; // 상위 10% 개체 수
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 관련 API
|
||||
*/
|
||||
export const dashboardApi = {
|
||||
/**
|
||||
* GET /dashboard/summary/:farmNo - 농장 현황 요약
|
||||
*/
|
||||
getFarmSummary: async (farmNo: number, filter?: DashboardFilterDto): Promise<any> => {
|
||||
return await apiClient.get(`/dashboard/summary/${farmNo}`, {
|
||||
params: filter,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /dashboard/analysis-completion/:farmNo - 분석 완료 현황
|
||||
*/
|
||||
getAnalysisCompletion: async (farmNo: number, filter?: DashboardFilterDto): Promise<any> => {
|
||||
return await apiClient.get(`/dashboard/analysis-completion/${farmNo}`, {
|
||||
params: filter,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /dashboard/evaluation/:farmNo - 농장 종합 평가
|
||||
*/
|
||||
getFarmEvaluation: async (farmNo: number, filter?: DashboardFilterDto): Promise<any> => {
|
||||
return await apiClient.get(`/dashboard/evaluation/${farmNo}`, {
|
||||
params: filter,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /dashboard/region-comparison/:farmNo - 보은군 비교 분석
|
||||
*/
|
||||
getRegionComparison: async (farmNo: number, filter?: DashboardFilterDto): Promise<any> => {
|
||||
return await apiClient.get(`/dashboard/region-comparison/${farmNo}`, {
|
||||
params: filter,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /dashboard/cow-distribution/:farmNo - 개체 분포 분석
|
||||
*/
|
||||
getCowDistribution: async (farmNo: number, filter?: DashboardFilterDto): Promise<any> => {
|
||||
return await apiClient.get(`/dashboard/cow-distribution/${farmNo}`, {
|
||||
params: filter,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /dashboard/kpn-aggregation/:farmNo - KPN 추천 집계
|
||||
*/
|
||||
getKpnRecommendationAggregation: async (farmNo: number, filter?: DashboardFilterDto): Promise<any> => {
|
||||
return await apiClient.get(`/dashboard/kpn-aggregation/${farmNo}`, {
|
||||
params: filter,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /dashboard/farm-kpn-inventory/:farmNo - 농장 보유 KPN 목록
|
||||
*/
|
||||
getFarmKpnInventory: async (farmNo: number): Promise<any> => {
|
||||
return await apiClient.get(`/dashboard/farm-kpn-inventory/${farmNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /dashboard/analysis-years/:farmNo - 농장 분석 이력 연도 목록
|
||||
*/
|
||||
getAnalysisYears: async (farmNo: number): Promise<number[]> => {
|
||||
return await apiClient.get(`/dashboard/analysis-years/${farmNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /dashboard/analysis-years/:farmNo/latest - 최신 분석 연도 조회
|
||||
*/
|
||||
getLatestAnalysisYear: async (farmNo: number): Promise<number> => {
|
||||
return await apiClient.get(`/dashboard/analysis-years/${farmNo}/latest`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /dashboard/year-comparison/:farmNo - 3개년 비교 분석
|
||||
*/
|
||||
getYearComparison: async (farmNo: number): Promise<any> => {
|
||||
return await apiClient.get(`/dashboard/year-comparison/${farmNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /dashboard/gene-status/:farmNo - 유전자 보유 현황 분석
|
||||
*/
|
||||
getGeneStatus: async (farmNo: number, filter?: DashboardFilterDto): Promise<any> => {
|
||||
return await apiClient.get(`/dashboard/gene-status/${farmNo}`, {
|
||||
params: filter,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /dashboard/repro-efficiency/:farmNo - 번식 효율성 분석
|
||||
*/
|
||||
getReproEfficiency: async (farmNo: number, filter?: DashboardFilterDto): Promise<any> => {
|
||||
return await apiClient.get(`/dashboard/repro-efficiency/${farmNo}`, {
|
||||
params: filter,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /dashboard/excellent-cows/:farmNo - 우수개체 추천
|
||||
*/
|
||||
getExcellentCows: async (farmNo: number, filter?: DashboardFilterDto): Promise<any> => {
|
||||
return await apiClient.get(`/dashboard/excellent-cows/${farmNo}`, {
|
||||
params: filter,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /dashboard/cull-cows/:farmNo - 도태개체 추천
|
||||
*/
|
||||
getCullCows: async (farmNo: number, filter?: DashboardFilterDto): Promise<any> => {
|
||||
return await apiClient.get(`/dashboard/cull-cows/${farmNo}`, {
|
||||
params: filter,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /dashboard/cattle-ranking/:farmNo - 보은군 내 소 개별 순위 조회
|
||||
*/
|
||||
getCattleRankingInRegion: async (farmNo: number, filter?: DashboardFilterDto): Promise<FarmCattleRankingsDto> => {
|
||||
return await apiClient.get(`/dashboard/cattle-ranking/${farmNo}`, {
|
||||
params: filter,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -17,44 +17,6 @@ export interface FarmDto {
|
||||
updtDt: Date;
|
||||
}
|
||||
|
||||
export interface FarmPaternityDto {
|
||||
pkFarmPaternityNo: number;
|
||||
fkFarmAnlysNo: number;
|
||||
receiptDate: Date;
|
||||
farmOwnerName: string;
|
||||
individualNo: string;
|
||||
kpnNo: string;
|
||||
motherIndividualNo: string;
|
||||
hairRootQuality: string;
|
||||
remarks: string;
|
||||
fatherMatch: string; // '일치', '불일치', '정보없음'
|
||||
motherMatch: string; // '일치', '불일치', '정보없음'
|
||||
reportDate: Date;
|
||||
}
|
||||
|
||||
export interface FarmAnalysisRequestDto {
|
||||
pkFarmAnlysNo: number;
|
||||
fkFileNo: number;
|
||||
fkFarmNo: number;
|
||||
farmAnlysNm: string;
|
||||
anlysReqDt: Date;
|
||||
region: string;
|
||||
city: string;
|
||||
anlysReqCnt: number;
|
||||
farmAnlysCnt: number;
|
||||
matchCnt: number;
|
||||
mismatchCnt: number;
|
||||
failCnt: number;
|
||||
noHistCnt: number;
|
||||
matchRate: number;
|
||||
msAnlysCnt: number;
|
||||
anlysRmrk: string;
|
||||
delYn: 'Y' | 'N';
|
||||
regDt: Date;
|
||||
updtDt: Date;
|
||||
paternities?: FarmPaternityDto[]; // 친자확인 목록
|
||||
}
|
||||
|
||||
export const farmApi = {
|
||||
/**
|
||||
* GET /farm - 현재 로그인한 사용자의 농장 목록 조회
|
||||
@@ -62,60 +24,4 @@ export const farmApi = {
|
||||
findAll: async (): Promise<FarmDto[]> => {
|
||||
return await apiClient.get('/farm');
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /farm/:id - 농장 상세 조회
|
||||
*/
|
||||
findOne: async (farmNo: number): Promise<FarmDto> => {
|
||||
return await apiClient.get(`/farm/${farmNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /farm - 농장 생성
|
||||
*/
|
||||
create: async (data: {
|
||||
userNo: number;
|
||||
farmName: string;
|
||||
farmAddress: string;
|
||||
farmBizNo: string;
|
||||
farmGovNo?: string;
|
||||
}): Promise<FarmDto> => {
|
||||
return await apiClient.post('/farm', data);
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /farm/:id - 농장 수정
|
||||
*/
|
||||
update: async (
|
||||
farmNo: number,
|
||||
data: {
|
||||
farmName?: string;
|
||||
farmAddress?: string;
|
||||
farmBizNo?: string;
|
||||
farmGovNo?: string;
|
||||
}
|
||||
): Promise<FarmDto> => {
|
||||
return await apiClient.patch(`/farm/${farmNo}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /farm/:id - 농장 삭제 (소프트 삭제)
|
||||
*/
|
||||
remove: async (farmNo: number): Promise<void> => {
|
||||
await apiClient.delete(`/farm/${farmNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /farm/:farmNo/analysis-latest - 농장 최신 분석 의뢰 정보 조회
|
||||
*/
|
||||
getLatestAnalysisRequest: async (farmNo: number): Promise<FarmAnalysisRequestDto | null> => {
|
||||
return await apiClient.get(`/farm/${farmNo}/analysis-latest`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /farm/:farmNo/analysis-all - 농장 전체 분석 의뢰 목록 조회
|
||||
*/
|
||||
getAllAnalysisRequests: async (farmNo: number): Promise<FarmAnalysisRequestDto[]> => {
|
||||
return await apiClient.get(`/farm/${farmNo}/analysis-all`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -30,28 +30,6 @@ export interface GeneDetail {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 요약 정보 타입
|
||||
*/
|
||||
export interface GeneSummary {
|
||||
total: number;
|
||||
homozygousCount: number;
|
||||
heterozygousCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 마커 정보 타입
|
||||
*/
|
||||
export interface MarkerModel {
|
||||
markerNo: number;
|
||||
markerNm: string;
|
||||
markerType: string; // 'QTY' | 'QLT'
|
||||
markerTypeCd?: string; // 'QTY' | 'QLT' (별칭)
|
||||
markerDesc?: string;
|
||||
description?: string;
|
||||
relatedTrait?: string;
|
||||
}
|
||||
|
||||
export const geneApi = {
|
||||
/**
|
||||
* 개체식별번호로 유전자 상세 정보 조회
|
||||
@@ -60,74 +38,4 @@ export const geneApi = {
|
||||
findByCowId: async (cowId: string): Promise<GeneDetail[]> => {
|
||||
return await apiClient.get(`/gene/${cowId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 개체별 유전자 요약 정보 조회
|
||||
* GET /gene/summary/:cowId
|
||||
*/
|
||||
getGeneSummary: async (cowId: string): Promise<GeneSummary> => {
|
||||
return await apiClient.get(`/gene/summary/${cowId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 의뢰번호로 유전자 상세 정보 조회
|
||||
* GET /gene/request/:requestNo
|
||||
*/
|
||||
findByRequestNo: async (requestNo: number): Promise<GeneDetail[]> => {
|
||||
return await apiClient.get(`/gene/request/${requestNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 유전자 상세 정보 단건 조회
|
||||
* GET /gene/detail/:geneDetailNo
|
||||
*/
|
||||
findOne: async (geneDetailNo: number): Promise<GeneDetail> => {
|
||||
return await apiClient.get(`/gene/detail/${geneDetailNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 유전자 상세 정보 생성
|
||||
* POST /gene
|
||||
*/
|
||||
create: async (data: Partial<GeneDetail>): Promise<GeneDetail> => {
|
||||
return await apiClient.post('/gene', data);
|
||||
},
|
||||
|
||||
/**
|
||||
* 유전자 상세 정보 일괄 생성
|
||||
* POST /gene/bulk
|
||||
*/
|
||||
createBulk: async (dataList: Partial<GeneDetail>[]): Promise<GeneDetail[]> => {
|
||||
return await apiClient.post('/gene/bulk', dataList);
|
||||
},
|
||||
|
||||
/**
|
||||
* 유전자 타입별 마커 목록 조회
|
||||
* GET /gene/markers/:type
|
||||
* TODO: 백엔드 API 구현 후 연동 필요
|
||||
*/
|
||||
getGenesByType: async (type: 'QTY' | 'QLT'): Promise<MarkerModel[]> => {
|
||||
try {
|
||||
return await apiClient.get(`/gene/markers/${type}`);
|
||||
} catch {
|
||||
// API 미구현 시 빈 배열 반환
|
||||
console.warn(`[Gene API] getGenesByType(${type}) - API 미구현, 빈 배열 반환`);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 전체 마커 목록 조회
|
||||
* GET /gene/markers
|
||||
* TODO: 백엔드 API 구현 후 연동 필요
|
||||
*/
|
||||
getAllMarkers: async (): Promise<MarkerModel[]> => {
|
||||
try {
|
||||
return await apiClient.get('/gene/markers');
|
||||
} catch {
|
||||
// API 미구현 시 빈 배열 반환
|
||||
console.warn('[Gene API] getAllMarkers() - API 미구현, 빈 배열 반환');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import apiClient from "../api-client";
|
||||
import {
|
||||
GenomeTrait,
|
||||
GeneticAbility,
|
||||
GeneticAbilityRequest,
|
||||
} from "@/types/genome.types";
|
||||
import { GenomeTrait } from "@/types/genome.types";
|
||||
|
||||
export interface CategoryAverageDto {
|
||||
category: string;
|
||||
@@ -64,13 +60,6 @@ export interface GenomeRequestDto {
|
||||
}
|
||||
|
||||
export const genomeApi = {
|
||||
/**
|
||||
* GET /genome - 모든 유전체 데이터 조회
|
||||
*/
|
||||
findAll: async (): Promise<GenomeTrait[]> => {
|
||||
return await apiClient.get("/genome");
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /genome/request/:cowNo - 개체식별번호로 유전체 분석 의뢰 정보 조회
|
||||
*
|
||||
@@ -89,19 +78,6 @@ export const genomeApi = {
|
||||
return await apiClient.get(`/genome/${cowNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /genome/:cowNo/genetic-ability - 개체의 유전능력 평가 조회
|
||||
*
|
||||
* @param cowNo - 개체 번호
|
||||
* @param request - 사용자가 선택한 유전체 형질과 가중치
|
||||
*/
|
||||
getGeneticAbility: async (
|
||||
cowNo: string | number,
|
||||
request: GeneticAbilityRequest = {}
|
||||
): Promise<GeneticAbility> => {
|
||||
return await apiClient.post(`/genome/${cowNo}/genetic-ability`, request);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /genome/comparison-averages/:cowNo - 전국/지역/농장 카테고리별 평균 비교
|
||||
*
|
||||
@@ -155,13 +131,6 @@ export const genomeApi = {
|
||||
return await apiClient.get(`/genome/dashboard-stats/${farmNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /genome/farm-trait-comparison/:farmNo - 농가별 형질 비교 (농가 vs 지역 vs 전국)
|
||||
*/
|
||||
getFarmTraitComparison: async (farmNo: number): Promise<FarmTraitComparisonDto> => {
|
||||
return await apiClient.get(`/genome/farm-trait-comparison/${farmNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /genome/farm-region-ranking/:farmNo - 농가의 보은군 내 순위 조회 (대시보드용)
|
||||
*/
|
||||
@@ -252,33 +221,6 @@ export interface FarmRegionRankingDto {
|
||||
regionCowCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 농가별 형질 비교 데이터 타입
|
||||
*/
|
||||
export interface FarmTraitComparisonDto {
|
||||
farmName: string;
|
||||
regionName: string;
|
||||
totalFarmAnimals: number;
|
||||
totalRegionAnimals: number;
|
||||
traits: {
|
||||
traitName: string;
|
||||
category: string;
|
||||
// 농가 데이터
|
||||
farmAvgEbv: number;
|
||||
farmCount: number;
|
||||
farmPercentile: number;
|
||||
// 지역 데이터
|
||||
regionAvgEbv: number;
|
||||
regionCount: number;
|
||||
// 전국 데이터
|
||||
nationAvgEbv: number;
|
||||
nationCount: number;
|
||||
// 비교
|
||||
diffFromRegion: number;
|
||||
diffFromNation: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 통계 데이터 타입
|
||||
*/
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* API 모듈 통합 Export
|
||||
*
|
||||
* @description
|
||||
* 모든 API 함수를 중앙에서 관리하고 export합니다.
|
||||
* 한 곳에서 내보내기 다른 파일에서 @/lib/api로 import하면 자동으로 index.ts를 통해 관리
|
||||
*
|
||||
* @example
|
||||
* import { cowApi, authApi } from '@/lib/api';
|
||||
*/
|
||||
|
||||
export { authApi } from './auth.api'; // 인증 API
|
||||
export { cowApi } from './cow.api';
|
||||
export { dashboardApi } from './dashboard.api';
|
||||
export { farmApi } from './farm.api';
|
||||
export { geneApi, type GeneDetail, type GeneSummary } from './gene.api';
|
||||
export { genomeApi, type ComparisonAveragesDto, type CategoryAverageDto, type FarmTraitComparisonDto, type TraitComparisonAveragesDto, type TraitAverageDto, type GenomeRequestDto } from './genome.api';
|
||||
export { reproApi } from './repro.api';
|
||||
export { breedApi } from './breed.api';
|
||||
|
||||
// API 클라이언트도 export (필요 시 직접 사용 가능)
|
||||
export { default as apiClient } from '../api-client';
|
||||
export { mptApi, type MptDto } from './mpt.api';
|
||||
@@ -1,5 +1,37 @@
|
||||
import apiClient from "../api-client";
|
||||
|
||||
/**
|
||||
* MPT 참조값 인터페이스
|
||||
*/
|
||||
export interface MptReferenceRange {
|
||||
key: string;
|
||||
name: string;
|
||||
upperLimit: number | null;
|
||||
lowerLimit: number | null;
|
||||
unit: string;
|
||||
category: 'energy' | 'protein' | 'liver' | 'mineral' | 'etc';
|
||||
categoryName: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MPT 카테고리 인터페이스
|
||||
*/
|
||||
export interface MptCategory {
|
||||
key: string;
|
||||
name: string;
|
||||
color: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* MPT 참조값 응답
|
||||
*/
|
||||
export interface MptReferenceResponse {
|
||||
references: Record<string, MptReferenceRange>;
|
||||
categories: MptCategory[];
|
||||
}
|
||||
|
||||
/**
|
||||
* MPT 통계 응답 DTO
|
||||
*/
|
||||
@@ -64,22 +96,6 @@ export interface MptDto {
|
||||
* MPT(혈액화학검사) 관련 API
|
||||
*/
|
||||
export const mptApi = {
|
||||
/**
|
||||
* GET /mpt - 전체 검사 결과 목록
|
||||
*/
|
||||
findAll: async (): Promise<MptDto[]> => {
|
||||
return await apiClient.get("/mpt");
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /mpt?farmId=:farmId - 특정 농장의 검사 결과
|
||||
*/
|
||||
findByFarmId: async (farmId: number): Promise<MptDto[]> => {
|
||||
return await apiClient.get("/mpt", {
|
||||
params: { farmId },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /mpt?cowShortNo=:cowShortNo - 특정 개체의 검사 결과 (뒤 4자리)
|
||||
*/
|
||||
@@ -98,47 +114,17 @@ export const mptApi = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /mpt/:id - 검사 결과 상세 조회
|
||||
*/
|
||||
findOne: async (id: number): Promise<MptDto> => {
|
||||
return await apiClient.get(`/mpt/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /mpt - 검사 결과 생성
|
||||
*/
|
||||
create: async (data: Partial<MptDto>): Promise<MptDto> => {
|
||||
return await apiClient.post("/mpt", data);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /mpt/bulk - 검사 결과 일괄 생성
|
||||
*/
|
||||
bulkCreate: async (data: Partial<MptDto>[]): Promise<MptDto[]> => {
|
||||
return await apiClient.post("/mpt/bulk", data);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /mpt/:id - 검사 결과 수정
|
||||
*/
|
||||
update: async (id: number, data: Partial<MptDto>): Promise<MptDto> => {
|
||||
return await apiClient.put(`/mpt/${id}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /mpt/:id - 검사 결과 삭제
|
||||
*/
|
||||
remove: async (id: number): Promise<void> => {
|
||||
return await apiClient.delete(`/mpt/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /mpt/statistics/:farmNo - 농장별 MPT 통계 조회
|
||||
* - 카테고리별 정상/주의/위험 개체 수
|
||||
* - 위험 개체 목록
|
||||
*/
|
||||
getMptStatistics: async (farmNo: number): Promise<MptStatisticsDto> => {
|
||||
return await apiClient.get(`/mpt/statistics/${farmNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /mpt/reference - MPT 참조값 조회
|
||||
*/
|
||||
getReferenceValues: async (): Promise<MptReferenceResponse> => {
|
||||
return await apiClient.get("/mpt/reference");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import apiClient from '../api-client';
|
||||
import { ReproMpt } from '@/types/mpt.types';
|
||||
|
||||
/**
|
||||
* 번식정보(Reproduction) 관련 API - MPT(혈액검사) 전용
|
||||
*/
|
||||
|
||||
export const reproApi = {
|
||||
/**
|
||||
* GET /repro/mpt - 전체 혈액검사(MPT) 정보 조회
|
||||
*/
|
||||
findAllMpt: async (): Promise<ReproMpt[]> => {
|
||||
return await apiClient.get('/repro/mpt');
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /repro/mpt/:cowNo - 특정 개체의 혈액검사 정보 조회
|
||||
*
|
||||
* @param cowNo - 개체 번호
|
||||
*/
|
||||
findMptByCowNo: async (cowNo: string | number): Promise<ReproMpt[]> => {
|
||||
return await apiClient.get(`/repro/mpt/${cowNo}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /repro/mpt-average/category - 전체 농가 MPT 평균 (카테고리별)
|
||||
*/
|
||||
getMptAverageByCategory: async (): Promise<Array<{ category: string; score: number }>> => {
|
||||
return await apiClient.get('/repro/mpt-average/category');
|
||||
},
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* 차트 색상 및 스타일 정의
|
||||
*/
|
||||
|
||||
export const CHART_COLORS = {
|
||||
primary: '#3b82f6',
|
||||
secondary: '#8b5cf6',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
info: '#06b6d4',
|
||||
muted: '#94a3b8',
|
||||
grid: '#e2e8f0',
|
||||
}
|
||||
|
||||
export const TOOLTIP_STYLE = {
|
||||
contentStyle: {
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
}
|
||||
|
||||
export const AXIS_STYLE = {
|
||||
axisLine: { stroke: '#e2e8f0' },
|
||||
tickLine: { stroke: '#e2e8f0' },
|
||||
tick: { fill: '#64748b', fontSize: 12 },
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* 유틸리티 함수 모음
|
||||
*
|
||||
* cn(): Tailwind CSS 클래스 병합 (shadcn/ui 필수)
|
||||
* - clsx로 조건부 클래스 결합 후 tailwind-merge로 충돌 해결
|
||||
* - 42개 컴포넌트에서 사용 중
|
||||
*
|
||||
* formatCowNo(): 개체번호 포맷팅 (3-4-4-1 형식)
|
||||
*/
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { create } from 'zustand'; // Zustand 상태 관리 라이브러리
|
||||
import { persist } from 'zustand/middleware'; // Zustand 상태 영속화 미들웨어
|
||||
// TypeScript/Node 모듈 규칙 - 폴더를 import 하면 자동으로 그 안의 index 파일을 찾는 규칙
|
||||
import { authApi } from '@/lib/api'; // tsconfig.json의 paths 규칙에 따라 절대경로 import
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { authApi } from '@/lib/api/auth.api';
|
||||
import { UserDto, LoginDto, SignupDto } from '@/types/auth.types';
|
||||
|
||||
/**
|
||||
@@ -12,8 +11,7 @@ import { UserDto, LoginDto, SignupDto } from '@/types/auth.types';
|
||||
interface AuthState {
|
||||
// 상태
|
||||
user: UserDto | null; // 현재 로그인한 사용자 정보
|
||||
accessToken: string | null; // JWT 액세스 토큰
|
||||
refreshToken: string | null; // JWT 리프레시 토큰
|
||||
accessToken: string | null; // JWT 액세스 토큰 (24시간 유효)
|
||||
isAuthenticated: boolean; // 로그인 여부
|
||||
|
||||
|
||||
@@ -21,7 +19,6 @@ interface AuthState {
|
||||
login: (dto: LoginDto) => Promise<void>; // 로그인
|
||||
signup: (dto: SignupDto) => Promise<void>; // 회원가입
|
||||
logout: () => Promise<void>; // 로그아웃
|
||||
refreshAuth: () => Promise<void>; // 토큰 갱신
|
||||
loadProfile: () => Promise<void>; // 프로필 불러오기
|
||||
clearAuth: () => void; // 인증 정보 초기화 (클라이언트에서)
|
||||
}
|
||||
@@ -45,7 +42,6 @@ export const useAuthStore = create<AuthState>()(
|
||||
// 초기 상태
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
/**
|
||||
@@ -54,18 +50,13 @@ export const useAuthStore = create<AuthState>()(
|
||||
*/
|
||||
login: async (dto: LoginDto) => {
|
||||
try {
|
||||
const response = await authApi.login(dto); // authApi의 login 함수 호출
|
||||
//authApi는 auth.api.ts에 정의되어 있음
|
||||
//import { authApi } from '@/lib/api'; 임포트를 @/lib/api 로 했기 때문에
|
||||
//자동으로 index.ts를 찾아가서 authApi를 가져옴
|
||||
//직접 auth.api.ts를 import 하려면 '@/lib/api/auth.api'로 해야함
|
||||
|
||||
const response = await authApi.login(dto);
|
||||
|
||||
// 받아온 결과를 상태에 저장
|
||||
// Zustand 상태 업데이트 (persist 미들웨어가 자동으로 localStorage에 저장)
|
||||
set({
|
||||
set({
|
||||
user: response.user,
|
||||
accessToken: response.accessToken,
|
||||
refreshToken: response.refreshToken || null,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -85,7 +76,6 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({
|
||||
user: response.user,
|
||||
accessToken: response.accessToken,
|
||||
refreshToken: response.refreshToken || null,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -107,39 +97,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 토큰 갱신
|
||||
*/
|
||||
refreshAuth: async () => {
|
||||
try {
|
||||
const { refreshToken } = get();
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('리프레시 토큰이 없습니다.');
|
||||
}
|
||||
|
||||
const response = await authApi.refreshToken(refreshToken);
|
||||
|
||||
// Zustand 상태 업데이트 (persist 미들웨어가 자동으로 localStorage에 저장)
|
||||
set({
|
||||
user: response.user,
|
||||
accessToken: response.accessToken,
|
||||
refreshToken: response.refreshToken || refreshToken,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
} catch (error) {
|
||||
// 토큰 갱신 실패 시 로그아웃 처리
|
||||
get().clearAuth();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 프로필 불러오기
|
||||
*/
|
||||
@@ -165,7 +127,6 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
},
|
||||
@@ -176,7 +137,6 @@ export const useAuthStore = create<AuthState>()(
|
||||
// localStorage에 저장할 필드만 선택
|
||||
user: state.user,
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
/**
|
||||
* 대시보드 보기 모드 설정 저장 Store
|
||||
*/
|
||||
|
||||
export type ViewMode = 'basic' | 'yield' | 'quality'
|
||||
|
||||
interface ViewPreferenceStore {
|
||||
viewMode: ViewMode
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
}
|
||||
|
||||
export const useViewPreferenceStore = create<ViewPreferenceStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
viewMode: 'basic',
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: 'view-preference-storage',
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -3,13 +3,11 @@
|
||||
* 백엔드 UserModel Entity 및 Auth DTOs 기준으로 작성
|
||||
*/
|
||||
|
||||
import { BaseFields } from './base.types';
|
||||
|
||||
/**
|
||||
* 사용자 정보
|
||||
* 백엔드 UserModel Entity와 일치
|
||||
*/
|
||||
export interface UserDto extends BaseFields {
|
||||
export interface UserDto {
|
||||
pkUserNo: number; // 내부 PK (자동증가)
|
||||
userId: string; // 로그인 ID
|
||||
userName: string; // 이름
|
||||
@@ -52,7 +50,6 @@ export interface LoginDto {
|
||||
export interface LoginResponseDto {
|
||||
message: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
user: {
|
||||
pkUserNo: number;
|
||||
userId: string;
|
||||
@@ -112,12 +109,11 @@ export interface SignupFormData {
|
||||
|
||||
/**
|
||||
* 인증 응답 DTO (통합)
|
||||
* 로그인/회원가입/토큰갱신 응답에 사용
|
||||
* 로그인/회원가입 응답에 사용
|
||||
*/
|
||||
export interface AuthResponseDto {
|
||||
message?: string;
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
user: UserDto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* 공통 필드
|
||||
*
|
||||
* @export
|
||||
* @interface BaseFields
|
||||
* @typedef {BaseFields}
|
||||
*/
|
||||
export interface BaseFields {
|
||||
regDt: string; //등록일시
|
||||
updtDt: string; //수정일시
|
||||
regIp?: string; //등록 IP
|
||||
regUserId?: string; //등록자 ID
|
||||
updtIp?: string; //수정 IP
|
||||
updtUserId?: string; //수정자 ID
|
||||
}
|
||||
@@ -3,14 +3,11 @@
|
||||
* 백엔드 CowModel Entity 기준으로 작성
|
||||
*/
|
||||
|
||||
import { BaseFields } from './base.types';
|
||||
import { FarmDto } from './farm.types';
|
||||
|
||||
/**
|
||||
* 개체 기본 정보
|
||||
* 백엔드 CowModel Entity와 일치
|
||||
*/
|
||||
export interface CowDto extends BaseFields {
|
||||
export interface CowDto {
|
||||
pkCowNo: number; // 내부 PK (자동증가)
|
||||
cowId: string; // 개체식별번호 (KOR 또는 KPN) - 필수
|
||||
cowSex?: string; // 성별 (M/F)
|
||||
@@ -27,7 +24,11 @@ export interface CowDto extends BaseFields {
|
||||
mptMonthAge?: number; // MPT 검사일 기준 월령
|
||||
|
||||
// Relations
|
||||
farm?: FarmDto; // 농장 정보 (조인)
|
||||
farm?: {
|
||||
pkFarmNo: number;
|
||||
farmNm?: string;
|
||||
farmAddr?: string;
|
||||
}; // 농장 정보 (조인)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* 대시보드 관련 타입 정의
|
||||
*/
|
||||
|
||||
/**
|
||||
* 대시보드 요약 정보
|
||||
*/
|
||||
export interface DashboardSummaryDto {
|
||||
totalCows: number; // 전체 개체 수
|
||||
totalFarms: number; // 전체 농장 수
|
||||
totalOwnedKpns: number; // 보유 KPN 수
|
||||
recentRecommendations: number; // 최근 7일 추천 수
|
||||
inbreedingRiskCount: number; // 근친도 위험 개체 수
|
||||
averageInbreeding: number; // 평균 근친도 (%)
|
||||
topGenes: TopGeneDto[]; // 상위 우량유전자 보유 현황
|
||||
}
|
||||
|
||||
/**
|
||||
* 상위 유전자 보유 현황
|
||||
*/
|
||||
export interface TopGeneDto {
|
||||
geneName: string; // 유전자명 (예: 'CAPN1')
|
||||
count: number; // 보유 개체 수
|
||||
percentage: number; // 전체 대비 비율 (%)
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장 통계
|
||||
*/
|
||||
export interface FarmStatisticsDto {
|
||||
farmNo: number; // 농장 번호
|
||||
farmName: string; // 농장명
|
||||
totalCows: number; // 전체 개체 수
|
||||
maleCows: number; // 수컷 개체 수
|
||||
femaleCows: number; // 암컷 개체 수
|
||||
averageAge: number; // 평균 월령
|
||||
averageInbreeding: number; // 평균 근친도 (%)
|
||||
geneDistribution: GeneDistributionItem[]; // 유전자 분포
|
||||
inbreedingDistribution: InbreedingDistributionDto; // 근친도 분포
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 분포 항목
|
||||
*/
|
||||
export interface GeneDistributionItem {
|
||||
geneName: string; // 유전자명
|
||||
count: number; // 보유 개체 수
|
||||
percentage: number; // 비율 (%)
|
||||
type: 'QUALITY' | 'QUANTITY'; // 유형 (육질형/육량형)
|
||||
}
|
||||
|
||||
/**
|
||||
* 근친도 분포
|
||||
*/
|
||||
export interface InbreedingDistributionDto {
|
||||
normal: number; // 정상 범위 개체 수 (<6.25%)
|
||||
warning: number; // 주의 범위 개체 수 (6.25~12.5%)
|
||||
danger: number; // 위험 범위 개체 수 (>12.5%)
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 활동 내역
|
||||
*/
|
||||
export interface RecentActivitiesDto {
|
||||
activities: ActivityDto[]; // 활동 목록
|
||||
}
|
||||
|
||||
/**
|
||||
* 활동 항목
|
||||
*/
|
||||
export interface ActivityDto {
|
||||
id: number; // 활동 ID
|
||||
type: 'RECOMMENDATION' | 'SIMULATION' | 'COW_ADDED' | 'KPN_ADDED'; // 활동 유형
|
||||
description: string; // 활동 설명
|
||||
targetId?: number; // 대상 ID (개체번호 등)
|
||||
regDt: Date; // 활동 일시
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 분포 현황
|
||||
*/
|
||||
export interface GeneDistributionDto {
|
||||
totalGenes: number; // 전체 유전자 수
|
||||
qualityGenes: GeneDistributionItem[]; // 육질형 유전자 분포
|
||||
quantityGenes: GeneDistributionItem[]; // 육량형 유전자 분포
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장 현황 요약 (section-cards용)
|
||||
*/
|
||||
export interface FarmSummaryDto {
|
||||
totalCows: number; // 전체 개체 수
|
||||
analysisComplete: number; // 분석 완료 개체 수
|
||||
}
|
||||
|
||||
/**
|
||||
* 유전자 보유 현황
|
||||
*/
|
||||
export interface GenePossessionStatus {
|
||||
// 육질형 유전자 보유 현황
|
||||
meatQuality: {
|
||||
markers: {
|
||||
markerName: string; // 마커명
|
||||
possessionRate: number; // 보유율(%)
|
||||
regionAvg: number; // 보은군 평균(%)
|
||||
gap: number; // 차이(%p)
|
||||
}[];
|
||||
averageRate: number; // 육질형 평균 보유율(%)
|
||||
regionAvg: number; // 보은군 평균(%)
|
||||
};
|
||||
|
||||
// 육량형 유전자 보유 현황
|
||||
meatQuantity: {
|
||||
markers: {
|
||||
markerName: string;
|
||||
possessionRate: number;
|
||||
regionAvg: number;
|
||||
gap: number;
|
||||
}[];
|
||||
averageRate: number; // 육량형 평균 보유율(%)
|
||||
regionAvg: number; // 보은군 평균(%)
|
||||
};
|
||||
|
||||
// 농장 유전자 특성 요약
|
||||
farmCharacteristic: {
|
||||
dominantType: 'meatQuality' | 'meatQuantity' | 'balanced'; // 우세 타입
|
||||
meatQualityStrength: number; // 육질형 강도 (0-100)
|
||||
meatQuantityStrength: number; // 육량형 강도 (0-100)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 분포 분석
|
||||
*/
|
||||
export interface CowDistributionDto {
|
||||
farmNo: number;
|
||||
genePossessionDistribution: {
|
||||
meatQuality: {
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
};
|
||||
meatQuantity: {
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
};
|
||||
byMarker: {
|
||||
markerName: string;
|
||||
distribution: {
|
||||
AA: number;
|
||||
AB: number;
|
||||
BB: number;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
genomeScoreDistribution: {
|
||||
'0-20': number;
|
||||
'20-40': number;
|
||||
'40-60': number;
|
||||
'60-80': number;
|
||||
'80-100': number;
|
||||
};
|
||||
fertilityScoreDistribution: {
|
||||
'0-20': number;
|
||||
'20-40': number;
|
||||
'40-60': number;
|
||||
'60-80': number;
|
||||
'80-100': number;
|
||||
};
|
||||
totalScoreDistribution: {
|
||||
'0-20': number;
|
||||
'20-40': number;
|
||||
'40-60': number;
|
||||
'60-80': number;
|
||||
'80-100': number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장 종합 평가
|
||||
*/
|
||||
export interface FarmEvaluationDto {
|
||||
farmNo: number; // 농장 번호
|
||||
farmName: string; // 농장명
|
||||
genePossession: GenePossessionStatus; // 유전자 보유 현황
|
||||
genomeScore: number; // 유전체 평균 점수
|
||||
genomeRank: number; // 유전체 순위
|
||||
genomeTotalFarms: number; // 전체 농장 수
|
||||
fertilityScore: number; // 번식능력 점수
|
||||
fertilityRank: number; // 번식능력 순위
|
||||
fertilityTotalFarms: number; // 전체 농장 수
|
||||
evaluationDate: Date; // 평가 일자
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* 농장 관련 타입 정의
|
||||
* 백엔드 FarmModel Entity 기준으로 작성
|
||||
*/
|
||||
|
||||
import { BaseFields } from './base.types';
|
||||
|
||||
/**
|
||||
* 농장 기본 정보
|
||||
* 백엔드 FarmModel Entity와 일치
|
||||
*/
|
||||
export interface FarmDto extends BaseFields {
|
||||
pkFarmNo: number; // 내부 PK (자동증가)
|
||||
traceFarmNo?: string; // 축평원 농장번호 (나중에 입력)
|
||||
fkUserNo?: number; // 사용자정보 FK
|
||||
farmerName?: string; // 농장주명 (농가명을 농장주로 사용)
|
||||
regionSi?: string; // 시군
|
||||
regionGu?: string; // 시/군/구 (지역)
|
||||
roadAddress?: string; // 도로명 주소
|
||||
delDt?: string; // 삭제일시 (Soft Delete)
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장 목록 응답
|
||||
*/
|
||||
export interface FarmListResponseDto {
|
||||
totalCount: number; // 전체 농장 수
|
||||
farms: FarmDto[]; // 농장 목록
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장 상세 응답 (프론트엔드 확장)
|
||||
*/
|
||||
export interface FarmDetailResponseDto extends FarmDto {
|
||||
cowCount?: number; // 보유 개체 수
|
||||
ownerName?: string; // 소유자 이름
|
||||
ownerPhone?: string; // 소유자 전화번호
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장 등록 DTO
|
||||
*/
|
||||
export interface CreateFarmDto {
|
||||
traceFarmNo?: string; // 축평원 농장번호
|
||||
farmerName: string; // 농장주명 (필수)
|
||||
regionSi?: string; // 시군
|
||||
regionGu?: string; // 시/군/구
|
||||
roadAddress?: string; // 도로명 주소
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장 수정 DTO
|
||||
*/
|
||||
export interface UpdateFarmDto {
|
||||
traceFarmNo?: string; // 축평원 농장번호
|
||||
farmerName?: string; // 농장주명
|
||||
regionSi?: string; // 시군
|
||||
regionGu?: string; // 시/군/구
|
||||
roadAddress?: string; // 도로명 주소
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장 검색 DTO
|
||||
*/
|
||||
export interface FarmSearchDto {
|
||||
keyword?: string; // 검색 키워드 (농장명, 농장주명 등)
|
||||
traceFarmNo?: string; // 축평원 농장번호
|
||||
regionSi?: string; // 시군
|
||||
regionGu?: string; // 시/군/구
|
||||
}
|
||||
|
||||
// 타입 alias (호환성)
|
||||
export type Farm = FarmDto;
|
||||
export type FarmList = FarmListResponseDto;
|
||||
export type FarmDetail = FarmDetailResponseDto;
|
||||
@@ -3,13 +3,11 @@
|
||||
* 백엔드 API 응답 형식 기준으로 작성
|
||||
*/
|
||||
|
||||
import { BaseFields } from './base.types';
|
||||
|
||||
/**
|
||||
* 유전체 분석 의뢰 정보
|
||||
* 백엔드 GenomeRequestModel Entity와 일치
|
||||
*/
|
||||
export interface GenomeRequestDto extends BaseFields {
|
||||
export interface GenomeRequestDto {
|
||||
pkRequestNo: number; // No (PK)
|
||||
fkFarmNo?: number; // 농장번호 FK
|
||||
fkCowNo?: number; // 개체번호 FK
|
||||
@@ -64,7 +62,7 @@ export interface GenomeCow {
|
||||
*
|
||||
* NOTE: genome_trait 테이블이 삭제되어 genome_trait_detail이 직접 genome_request와 연결됨
|
||||
*/
|
||||
export interface GenomeTraitDto extends BaseFields {
|
||||
export interface GenomeTraitDto {
|
||||
fkRequestNo?: number; // 의뢰번호 FK
|
||||
|
||||
// API 응답 필드
|
||||
@@ -85,7 +83,7 @@ export interface GenomeTraitDto extends BaseFields {
|
||||
*
|
||||
* NOTE: fk_trait_no가 fk_request_no로 변경됨 (genome_trait 테이블 삭제로 인해)
|
||||
*/
|
||||
export interface GenomeTraitDetailDto extends BaseFields {
|
||||
export interface GenomeTraitDetailDto {
|
||||
pkTraitDetailNo: number; // 형질상세번호 PK
|
||||
fkRequestNo: number; // 의뢰번호 FK (변경됨: fkTraitNo → fkRequestNo)
|
||||
cowId?: string; // 개체식별번호 (KOR...) - 추가됨
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* MPT(혈액검사) 관련 타입 정의
|
||||
* 백엔드 MptModel Entity 기준으로 작성
|
||||
*/
|
||||
|
||||
import { BaseFields } from './base.types';
|
||||
|
||||
/**
|
||||
* MPT 혈액검사 정보
|
||||
* 백엔드 MptModel Entity와 일치
|
||||
*/
|
||||
export interface MptDto extends BaseFields {
|
||||
pkMptNo: number; // MPT 번호 (PK)
|
||||
cowShortNo?: string; // 개체 요약번호
|
||||
fkFarmNo?: number; // 농장번호 FK
|
||||
testDt?: string; // 검사일자
|
||||
monthAge?: number; // 월령
|
||||
milkYield?: number; // 유량
|
||||
parity?: number; // 산차
|
||||
|
||||
// 에너지 (3개)
|
||||
glucose?: number; // 혈당 (mg/dL)
|
||||
cholesterol?: number; // 콜레스테롤 (mg/dL)
|
||||
nefa?: number; // 유리지방산(NEFA) (μEq/L)
|
||||
bcs?: number; // BCS
|
||||
|
||||
// 단백질 (5개)
|
||||
totalProtein?: number; // 총단백질 (g/dL)
|
||||
albumin?: number; // 알부민 (g/dL)
|
||||
globulin?: number; // 총글로불린 (g/dL)
|
||||
agRatio?: number; // A/G 비율
|
||||
bun?: number; // 요소태질소(BUN) (mg/dL)
|
||||
|
||||
// 간기능 (3개)
|
||||
ast?: number; // AST (U/L)
|
||||
ggt?: number; // GGT (U/L)
|
||||
fattyLiverIdx?: number; // 지방간지수
|
||||
|
||||
// 미네랄 (5개)
|
||||
calcium?: number; // 칼슘 (mg/dL)
|
||||
phosphorus?: number; // 인 (mg/dL)
|
||||
caPRatio?: number; // 칼슘/인 비율
|
||||
magnesium?: number; // 마그네슘 (mg/dL)
|
||||
creatine?: number; // 크레아틴 (mg/dL)
|
||||
|
||||
delDt?: string; // 삭제일시 (Soft Delete)
|
||||
}
|
||||
|
||||
/**
|
||||
* MPT 혈액검사 목록 응답
|
||||
*/
|
||||
export interface MptListResponseDto {
|
||||
totalCount: number; // 전체 검사 수
|
||||
mpts: MptDto[]; // MPT 검사 목록
|
||||
}
|
||||
|
||||
/**
|
||||
* MPT 혈액검사 상세 응답 (프론트엔드 확장)
|
||||
*/
|
||||
export interface MptDetailResponseDto extends MptDto {
|
||||
cowInfo?: {
|
||||
cowNo: string; // 개체번호
|
||||
cowBirthDt?: string; // 생년월일
|
||||
grade?: string; // 등급
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 프론트엔드 호환용 ReproMpt 인터페이스
|
||||
* 기존 코드와의 호환성을 위해 유지
|
||||
*/
|
||||
export interface ReproMpt {
|
||||
pkMptNo?: number;
|
||||
cowShortNo?: string;
|
||||
fkFarmNo?: number;
|
||||
reproMptDate?: string; // testDt 매핑
|
||||
monthAge?: number;
|
||||
|
||||
// 에너지
|
||||
bloodSugar?: number; // glucose 매핑
|
||||
cholesterol?: number;
|
||||
nefa?: number;
|
||||
|
||||
// 단백질
|
||||
totalProtein?: number;
|
||||
albumin?: number;
|
||||
totalGlobulin?: number; // globulin 매핑
|
||||
agRatio?: number;
|
||||
bun?: number;
|
||||
|
||||
// 간기능
|
||||
ast?: number;
|
||||
ggt?: number;
|
||||
fattyLiverIndex?: number; // fattyLiverIdx 매핑
|
||||
|
||||
// 미네랄
|
||||
calcium?: number;
|
||||
phosphorus?: number;
|
||||
caPRatio?: number;
|
||||
magnesium?: number;
|
||||
creatine?: number;
|
||||
|
||||
reproMptNote?: string;
|
||||
regDt?: string;
|
||||
updtDt?: string;
|
||||
}
|
||||
|
||||
// 타입 alias (호환성)
|
||||
export type Mpt = MptDto;
|
||||
export type MptList = MptListResponseDto;
|
||||
export type MptDetail = MptDetailResponseDto;
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* 페이지네이션 관련 타입
|
||||
*/
|
||||
|
||||
export interface PaginationMeta {
|
||||
page: number;
|
||||
limit: number;
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
lastPage: number;
|
||||
limit?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user