필터 및 화면 수정사항 반영

This commit is contained in:
2025-12-18 17:01:24 +09:00
parent 4d0f8f3b6b
commit abc2f20495
19 changed files with 417 additions and 5574 deletions

View File

@@ -542,10 +542,10 @@ export function CategoryEvaluationCard({
const selectedTrait = traitChartData.find(t => t.name === selectedTraitName)
if (!selectedTrait) return null
return (
<div className="mx-4 mb-4 p-4 bg-slate-50 rounded-xl border border-slate-200 animate-fade-in">
<div className="mx-2 mb-4 p-3 bg-slate-50 rounded-xl border border-slate-200 animate-fade-in">
{/* 헤더: 형질명 + 닫기 */}
<div className="flex items-center justify-between mb-4">
<span className="px-4 py-1.5 bg-slate-200 text-slate-700 text-base font-bold rounded-full">
<div className="flex items-center justify-between mb-3">
<span className="px-4 py-1.5 bg-slate-200 text-slate-700 text-sm font-bold rounded-full">
{selectedTrait.shortName}
</span>
<button
@@ -556,25 +556,25 @@ export function CategoryEvaluationCard({
</button>
</div>
{/* 3개 카드 그리드 */}
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-3 gap-2">
{/* 보은군 카드 */}
<div className="flex flex-col items-center justify-center p-3 bg-white rounded-xl border-2 border-emerald-300 shadow-sm">
<span className="text-xs text-muted-foreground mb-1 font-medium"> </span>
<span className="text-lg font-bold text-emerald-600">
<div className="flex flex-col items-center justify-center px-3 py-4 bg-white rounded-xl border-2 border-emerald-300 shadow-sm">
<span className="text-xs text-slate-600 mb-1 font-semibold whitespace-nowrap"> </span>
<span className="text-xl font-bold text-emerald-600">
{selectedTrait.regionEpd?.toFixed(2) ?? '-'}
</span>
</div>
{/* 농가 카드 */}
<div className="flex flex-col items-center justify-center p-3 bg-white rounded-xl border-2 border-[#1F3A8F]/30 shadow-sm">
<span className="text-xs text-muted-foreground mb-1 font-medium"> </span>
<span className="text-lg font-bold text-[#1F3A8F]">
<div className="flex flex-col items-center justify-center px-3 py-4 bg-white rounded-xl border-2 border-[#1F3A8F]/30 shadow-sm">
<span className="text-xs text-slate-600 mb-1 font-semibold whitespace-nowrap"> </span>
<span className="text-xl font-bold text-[#1F3A8F]">
{selectedTrait.farmEpd?.toFixed(2) ?? '-'}
</span>
</div>
{/* 개체 카드 */}
<div className="flex flex-col items-center justify-center p-3 bg-white rounded-xl border-2 border-[#1482B0]/30 shadow-sm">
<span className="text-xs text-muted-foreground mb-1 font-medium"> </span>
<span className="text-lg font-bold text-[#1482B0]">
<div className="flex flex-col items-center justify-center px-3 py-4 bg-white rounded-xl border-2 border-[#1482B0]/30 shadow-sm">
<span className="text-xs text-slate-600 mb-1 font-semibold whitespace-nowrap"> </span>
<span className="text-xl font-bold text-[#1482B0]">
{selectedTrait.epd?.toFixed(2) ?? '-'}
</span>
</div>

View File

@@ -811,10 +811,10 @@ export function NormalDistributionChart({
return Math.max(chartX + halfWidth + 5, Math.min(x, chartX + chartWidth - halfWidth - 5))
}
// 배지 크기 (더 크게)
// 배지 크기 (더 크게) - 모바일에서 텍스트가 한 줄로 나오도록 너비 확보
const cowBadgeW = isMobile ? 105 : 135
const avgBadgeW = isMobile ? 100 : 135
const regionBadgeW = isMobile ? 105 : 145
const avgBadgeW = isMobile ? 118 : 135
const regionBadgeW = isMobile ? 125 : 145
const badgeH = isMobile ? 42 : 48
// Y 위치 계산 - 겹치지 않게 배치

View File

@@ -17,6 +17,7 @@ import { GenomeTrait } from "@/types/genome.types"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
import {
ArrowLeft,
ArrowUp,
BarChart3,
CheckCircle2,
Download,
@@ -156,6 +157,7 @@ export default function CowOverviewPage() {
const [geneDataLoading, setGeneDataLoading] = useState(false) // 유전자 데이터 로딩 중
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<string>('genome')
const [showScrollTop, setShowScrollTop] = useState(false)
// 검사 상태
const [hasGenomeData, setHasGenomeData] = useState(false)
@@ -220,6 +222,20 @@ export default function CowOverviewPage() {
}
}, [filters.isActive, firstPinnedTrait, chartFilterTrait])
// 스크롤 투 탑 버튼 표시 여부
useEffect(() => {
const handleScroll = () => {
setShowScrollTop(window.scrollY > 400)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// 맨 위로 스크롤
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// 유전자 탭 필터 상태
const [geneSearchInput, setGeneSearchInput] = useState('') // 실시간 입력값
const [geneSearchKeyword, setGeneSearchKeyword] = useState('') // 디바운스된 검색값
@@ -1938,6 +1954,17 @@ export default function CowOverviewPage() {
</div>
</DialogContent>
</Dialog>
{/* 플로팅 맨 위로 버튼 */}
{showScrollTop && (
<button
onClick={scrollToTop}
className="fixed bottom-6 right-6 z-50 w-12 h-12 bg-primary text-white rounded-full shadow-lg hover:bg-primary/90 transition-all duration-300 flex items-center justify-center hover:scale-110 active:scale-95"
aria-label="맨 위로"
>
<ArrowUp className="w-6 h-6" />
</button>
)}
</SidebarProvider>
</AuthGuard>
)

View File

@@ -973,7 +973,7 @@ function MyCowContent() {
})() : '-'}
</td>
<td className="cow-table-cell">
{cow.cowSex === "" ? "소" : "소"}
{cow.cowSex === "" ? "소" : "소"}
</td>
<td className="cow-table-cell">
{cow.damCowId && cow.damCowId !== '0' ? cow.damCowId : '-'}
@@ -1132,7 +1132,7 @@ function MyCowContent() {
<div className="md:hidden space-y-2.5">
{paginatedCows.map((cow) => {
const rank = getRank(cow)
const isFemale = cow.cowSex === ''
const isFemale = cow.cowSex !== ''
return (
<div

View File

@@ -0,0 +1,162 @@
'use client'
import { useState } from 'react'
import { ArrowUp, ChevronUp, ChevronsUp, MoveUp } from 'lucide-react'
export default function FloatingButtonDemo() {
const [selectedStyle, setSelectedStyle] = useState<number>(1)
const styles = [
{
id: 1,
name: '기본 프라이머리',
className: 'bg-primary text-white shadow-lg hover:bg-primary/90',
},
{
id: 2,
name: '그라데이션 블루',
className: 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-xl shadow-blue-500/30 hover:shadow-blue-500/50',
},
{
id: 3,
name: '글래스모피즘',
className: 'bg-white/80 backdrop-blur-md border border-white/50 text-slate-700 shadow-lg hover:bg-white/90',
},
{
id: 4,
name: '아웃라인',
className: 'bg-white border-2 border-primary text-primary hover:bg-primary hover:text-white',
},
{
id: 5,
name: '미니멀 다크',
className: 'bg-slate-800 text-white shadow-lg hover:bg-slate-700',
},
{
id: 6,
name: '소프트 그레이',
className: 'bg-slate-100 text-slate-600 shadow-md hover:bg-slate-200 hover:text-slate-800',
},
{
id: 7,
name: '그린 그라데이션',
className: 'bg-gradient-to-r from-emerald-500 to-teal-600 text-white shadow-xl shadow-emerald-500/30',
},
{
id: 8,
name: '네온 퍼플',
className: 'bg-violet-600 text-white shadow-xl shadow-violet-500/40 hover:shadow-violet-500/60',
},
{
id: 9,
name: '오렌지 웜',
className: 'bg-gradient-to-r from-orange-400 to-rose-500 text-white shadow-xl shadow-orange-500/30',
},
{
id: 10,
name: '심플 화이트',
className: 'bg-white text-slate-500 shadow-xl border border-slate-200 hover:text-primary hover:border-primary',
},
]
const icons = [
{ id: 'arrow', icon: ArrowUp, name: 'ArrowUp' },
{ id: 'chevron', icon: ChevronUp, name: 'ChevronUp' },
{ id: 'chevrons', icon: ChevronsUp, name: 'ChevronsUp' },
{ id: 'move', icon: MoveUp, name: 'MoveUp' },
]
const [selectedIcon, setSelectedIcon] = useState('arrow')
const SelectedIconComponent = icons.find(i => i.id === selectedIcon)?.icon || ArrowUp
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-2"> </h1>
<p className="text-slate-600 mb-8"> . .</p>
{/* 아이콘 선택 */}
<div className="mb-8">
<h2 className="text-lg font-semibold mb-4"> </h2>
<div className="flex gap-3">
{icons.map((icon) => {
const IconComp = icon.icon
return (
<button
key={icon.id}
onClick={() => setSelectedIcon(icon.id)}
className={`flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all ${
selectedIcon === icon.id
? 'border-primary bg-primary/5'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<IconComp className="w-6 h-6" />
<span className="text-xs text-slate-600">{icon.name}</span>
</button>
)
})}
</div>
</div>
{/* 스타일 선택 그리드 */}
<h2 className="text-lg font-semibold mb-4"> </h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-12">
{styles.map((style) => (
<button
key={style.id}
onClick={() => setSelectedStyle(style.id)}
className={`p-4 rounded-xl border-2 transition-all ${
selectedStyle === style.id
? 'border-primary bg-primary/5'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<div className="flex justify-center mb-3">
<div
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${style.className}`}
>
<SelectedIconComponent className="w-6 h-6" />
</div>
</div>
<p className="text-sm font-medium text-center">{style.name}</p>
</button>
))}
</div>
{/* 코드 표시 */}
<div className="bg-slate-900 rounded-xl p-6 mb-8">
<p className="text-slate-400 text-sm mb-2"> :</p>
<code className="text-green-400 text-sm break-all">
{`className="${styles.find(s => s.id === selectedStyle)?.className}"`}
</code>
</div>
{/* 스크롤 테스트용 더미 콘텐츠 */}
<div className="space-y-4">
<h2 className="text-lg font-semibold"> </h2>
<p className="text-slate-600"> .</p>
{Array.from({ length: 20 }).map((_, i) => (
<div key={i} className="p-6 bg-white rounded-xl border border-slate-200">
<h3 className="font-semibold mb-2"> #{i + 1}</h3>
<p className="text-slate-600">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</p>
</div>
))}
</div>
</div>
{/* 실제 플로팅 버튼 */}
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className={`fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full flex items-center justify-center transition-all duration-300 hover:scale-110 active:scale-95 ${
styles.find(s => s.id === selectedStyle)?.className
}`}
aria-label="맨 위로"
>
<SelectedIconComponent className="w-6 h-6" />
</button>
</div>
)
}

View File

@@ -134,7 +134,7 @@ function SortableTraitItem({
<GripVertical className="w-4 h-4 text-slate-400" />
</button>
{isPinned && <Pin className="w-3 h-3 text-amber-500" fill="currentColor" />}
<span className="font-medium text-sm min-w-0 truncate">{id}</span>
<span className="font-medium text-sm min-w-0 truncate">{TRAIT_DISPLAY_NAMES[id] || id}</span>
<div className="flex items-center gap-1.5 ml-auto">
<Button
variant="outline"
@@ -221,6 +221,30 @@ const TRAIT_DESCRIPTIONS: Record<string, string> = {
'갈비rate': '전체 대비 갈비 비율',
}
// 형질 표시 이름 (DB 키 -> 화면 표시용)
const TRAIT_DISPLAY_NAMES: Record<string, string> = {
'안심weight': '안심중량',
'등심weight': '등심중량',
'채끝weight': '채끝중량',
'목심weight': '목심중량',
'앞다리weight': '앞다리중량',
'우둔weight': '우둔중량',
'설도weight': '설도중량',
'사태weight': '사태중량',
'양지weight': '양지중량',
'갈비weight': '갈비중량',
'안심rate': '안심비율',
'등심rate': '등심비율',
'채끝rate': '채끝비율',
'목심rate': '목심비율',
'앞다리rate': '앞다리비율',
'우둔rate': '우둔비율',
'설도rate': '설도비율',
'사태rate': '사태비율',
'양지rate': '양지비율',
'갈비rate': '갈비비율',
}
type TraitName = keyof typeof DEFAULT_FILTER_SETTINGS.traitWeights
interface GlobalFilterDialogProps {
@@ -973,7 +997,7 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange, geneCou
>
<Checkbox checked={isSelected} />
<div className="flex-1">
<span className="font-medium text-sm">{trait}</span>
<span className="font-medium text-sm">{TRAIT_DISPLAY_NAMES[trait] || trait}</span>
{TRAIT_DESCRIPTIONS[trait] && (
<span className="text-xs text-muted-foreground ml-2">{TRAIT_DESCRIPTIONS[trait]}</span>
)}

View File

@@ -266,7 +266,7 @@ function SidebarTrigger({
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7 md:size-7", isMobile && "size-9", className)}
className={cn("size-7 md:size-7", isMobile && "size-11", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
@@ -274,7 +274,7 @@ function SidebarTrigger({
{...props}
>
{isMobile ? (
<Menu className="h-6 w-6" />
<Menu className="h-7 w-7" />
) : (
<PanelLeftIcon />
)}

View File

@@ -16,25 +16,25 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
// 에너지 카테고리
glucose: {
name: '혈당',
upperLimit: 72,
lowerLimit: 46.9,
upperLimit: 84,
lowerLimit: 40,
unit: 'mg/dL',
category: '에너지',
description: '에너지 대사 상태 지표',
},
cholesterol: {
name: '콜레스테롤',
upperLimit: 169,
lowerLimit: 117,
upperLimit: 252,
lowerLimit: 74,
unit: 'mg/dL',
category: '에너지',
description: '혈액 내 콜레스테롤 수치',
},
nefa: {
name: '유리지방산(NEFA)',
upperLimit: 382,
lowerLimit: 118,
unit: 'uEq/L',
upperLimit: 660,
lowerLimit: 115,
unit: 'μEq/L',
category: '에너지',
description: '혈액 내 유리지방산 수치',
},
@@ -50,8 +50,8 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
// 단백질 카테고리
totalProtein: {
name: '총단백질',
upperLimit: 8.5,
lowerLimit: 6.5,
upperLimit: 7.7,
lowerLimit: 6.2,
unit: 'g/dL',
category: '단백질',
description: '혈액 내 총단백질 수치',
@@ -68,7 +68,7 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
name: '총글로불린',
upperLimit: 36.1,
lowerLimit: 9.1,
unit: 'g/L',
unit: 'g/dL',
category: '단백질',
description: '혈액 내 총글로불린 수치',
},
@@ -152,7 +152,7 @@ export const MPT_REFERENCE_RANGES: Record<string, MptReferenceRange> = {
// 기타 카테고리
creatinine: {
name: '크레아티닌',
upperLimit: 2.0,
upperLimit: 1.3,
lowerLimit: 1.0,
unit: 'mg/dL',
category: '기타',

View File

@@ -39,6 +39,19 @@ export interface GeneSummary {
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 = {
/**
* 개체식별번호로 유전자 상세 정보 조회
@@ -87,4 +100,34 @@ export const geneApi = {
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

@@ -54,13 +54,7 @@ export function isValidGenomeAnalysis(
chipDamName: string | null | undefined,
cowId?: string | null,
): boolean {
// 1. 아비 일치 확인
if (chipSireName !== VALID_CHIP_SIRE_NAME) return false;
// 2. 어미 제외 조건 확인
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) return false;
// 3. 개별 제외 개체 확인
// 개별 제외 개체만 확인 (부/모 불일치여도 유전자 데이터 있으면 표시)
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
return true;

View File

@@ -128,7 +128,7 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
analysisIndex: "GENE",
selectedGenes: [],
pinnedGenes: [],
selectedTraits: ["도체중", "등심단면적", "등지방두께", "근내지방도", "체장", "체", "흉위"],
selectedTraits: ["도체중", "등심단면적", "등지방두께", "근내지방도", "등심weight", "체", "체고"],
pinnedTraits: [],
traitWeights: {
// 성장형질 (점수: 1 ~ 10, 미선택 시 0)
@@ -150,11 +150,11 @@ export const DEFAULT_FILTER_SETTINGS: GlobalFilterSettings = {
요각폭: 0,
좌골폭: 0,
곤폭: 0,
흉위: 1,
흉위: 0,
// 부위별무게 (점수: 1 ~ 10, 미선택 시 0) - DB 형질명과 일치
안심weight: 0,
등심weight: 0,
등심weight: 1,
채끝weight: 0,
목심weight: 0,
앞다리weight: 0,