미사용 파일정리

This commit is contained in:
2025-12-24 08:25:44 +09:00
parent 1644fcf241
commit 05d89fdfcd
120 changed files with 817 additions and 85913 deletions

View File

@@ -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])
}
// 폴리곤 차트용 데이터 생성 (선택된 형질 기반) - 보은군, 농가, 이 개체 비교

View File

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

View File

@@ -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 || '도체중'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,9 @@
/**
* useIsMobile - 모바일 화면 감지 훅 (768px 미만)
*
* 사용처:
* - sidebar.tsx: 모바일에서 사이드바 동작 변경
*/
import * as React from "react"
const MOBILE_BREAKPOINT = 768

View File

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

View File

@@ -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 });
},
/**
* 비밀번호 변경

View File

@@ -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}`);
},
};

View File

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

View File

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

View File

@@ -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`);
},
};

View File

@@ -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 [];
}
},
};

View File

@@ -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;
}[];
}
/**
* 대시보드 통계 데이터 타입
*/

View File

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

View File

@@ -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");
},
};

View File

@@ -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');
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}; // 농장 정보 (조인)
}
/**

View File

@@ -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; // 평가 일자
}

View File

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

View File

@@ -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...) - 추가됨

View File

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

View File

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