필터 UI 수정 및 대시보드 연동

This commit is contained in:
2025-12-11 11:21:07 +09:00
parent 886aa9abd9
commit a673fd9429
5 changed files with 430 additions and 140 deletions

View File

@@ -16,6 +16,7 @@ import {
YAxis
} from 'recharts'
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
// 카테고리 색상 (모던 & 다이나믹 - 생동감 있는 색상)
const CATEGORY_COLORS: Record<string, string> = {
@@ -184,9 +185,23 @@ export function NormalDistributionChart({
chartFilterTrait: externalChartFilterTrait,
onChartFilterTraitChange
}: NormalDistributionChartProps) {
const { filters } = useGlobalFilter()
// 필터에서 고정된 첫 번째 형질 (없으면 첫 번째 선택된 형질, 없으면 '도체중')
const firstPinnedTrait = filters.pinnedTraits?.[0] || selectedTraitData[0]?.name || '도체중'
// 차트 필터 - 선택된 형질 또는 전체 선발지수 (외부 제어 가능)
const [internalChartFilterTrait, setInternalChartFilterTrait] = useState<string>('overall')
// 필터 비활성 시 기본값은 첫 번째 고정 형질
const [internalChartFilterTrait, setInternalChartFilterTrait] = useState<string>(() => {
return filters.isActive ? 'overall' : firstPinnedTrait
})
// 필터 활성 상태 변경 시 기본값 업데이트
useEffect(() => {
if (!filters.isActive && internalChartFilterTrait === 'overall') {
setInternalChartFilterTrait(firstPinnedTrait)
}
}, [filters.isActive, firstPinnedTrait])
// 외부에서 제어하면 외부 값 사용, 아니면 내부 상태 사용
const chartFilterTrait = externalChartFilterTrait ?? internalChartFilterTrait
@@ -366,12 +381,15 @@ export function NormalDistributionChart({
<SelectValue placeholder="전체 선발지수" />
</SelectTrigger>
<SelectContent>
<SelectItem value="overall"> </SelectItem>
{selectedTraitData.length > 0 && (
{filters.isActive && (
<SelectItem value="overall"> </SelectItem>
)}
{/* 모든 형질 표시 (필터 설정과 무관) */}
{allTraits.length > 0 && (
<>
{/* 카테고리별로 그룹핑 */}
{(['성장', '생산', '체형', '무게', '비율'] as const).map((category) => {
const categoryTraits = selectedTraitData.filter(t => t.category === category)
const categoryTraits = allTraits.filter(t => t.category === category)
if (categoryTraits.length === 0) return null
return (
<div key={category}>

View File

@@ -193,8 +193,21 @@ export default function CowOverviewPage() {
const [highlightMode, setHighlightMode] = useState<'farm' | 'region' | null>(null)
const distributionChartRef = useRef<HTMLDivElement>(null)
// 필터에서 고정된 첫 번째 형질 (없으면 '도체중')
const firstPinnedTrait = filters.pinnedTraits?.[0] || '도체중'
// 차트 형질 필터 (전체 선발지수 또는 개별 형질)
const [chartFilterTrait, setChartFilterTrait] = useState<string>('overall')
// 필터 비활성 시 기본값은 첫 번째 고정 형질
const [chartFilterTrait, setChartFilterTrait] = useState<string>(() => {
return filters.isActive ? 'overall' : firstPinnedTrait
})
// 필터 활성 상태 변경 시 기본값 업데이트
useEffect(() => {
if (!filters.isActive && chartFilterTrait === 'overall') {
setChartFilterTrait(firstPinnedTrait)
}
}, [filters.isActive, firstPinnedTrait, chartFilterTrait])
// 유전자 탭 필터 상태
const [geneSearchKeyword, setGeneSearchKeyword] = useState('')

View File

@@ -16,6 +16,7 @@ import {
import { apiClient, farmApi } from "@/lib/api"
import { DashboardStatsDto, FarmRegionRankingDto, YearlyTraitTrendDto, genomeApi } from "@/lib/api/genome.api"
import { useAuthStore } from "@/store/auth-store"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
import {
AlertCircle,
CheckCircle2,
@@ -57,23 +58,50 @@ const TRAIT_CATEGORIES: Record<string, string[]> = {
export default function DashboardPage() {
const { user } = useAuthStore()
const { filters } = useGlobalFilter()
const [farmNo, setFarmNo] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState<DashboardStatsDto | null>(null)
const [farmRanking, setFarmRanking] = useState<FarmRegionRankingDto | null>(null)
// 필터에서 고정된 첫 번째 형질 (없으면 '도체중')
const firstPinnedTrait = filters.pinnedTraits?.[0] || '도체중'
// 연도별 육종가 추이 관련 state
const [selectedTrait, setSelectedTrait] = useState<string>(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('dashboard_trait') || '도체중'
return localStorage.getItem('dashboard_trait') || firstPinnedTrait
}
return '도체중'
return firstPinnedTrait
})
const [traitTrendData, setTraitTrendData] = useState<YearlyTraitTrendDto | null>(null)
const [traitTrendLoading, setTraitTrendLoading] = useState(false)
// 보은군 내 농가 위치 차트 분포기준 (선발지수 or 개별 형질)
const [distributionBasis, setDistributionBasis] = useState<string>('overall')
// 필터 활성 시 'overall', 비활성 시 고정된 첫 번째 형질
const [distributionBasis, setDistributionBasis] = useState<string>(() => {
return filters.isActive ? 'overall' : firstPinnedTrait
})
// 필터 변경 시 기본값 업데이트
useEffect(() => {
if (!filters.isActive && distributionBasis === 'overall') {
setDistributionBasis(firstPinnedTrait)
}
}, [filters.isActive, distributionBasis, firstPinnedTrait])
// 필터에서 고정된 형질이 변경되면 selectedTrait도 업데이트
useEffect(() => {
if (filters.pinnedTraits && filters.pinnedTraits.length > 0) {
const newFirstPinned = filters.pinnedTraits[0]
// 첫 번째 고정 형질로 변경
setSelectedTrait(newFirstPinned)
// distributionBasis가 overall이 아니면 첫 번째 고정 형질로 변경
if (distributionBasis !== 'overall') {
setDistributionBasis(newFirstPinned)
}
}
}, [filters.pinnedTraits])
// 모든 형질 목록 (평탄화)
const allTraits = Object.entries(TRAIT_CATEGORIES).flatMap(([cat, traits]) =>
@@ -431,7 +459,9 @@ export default function DashboardPage() {
<SelectValue placeholder="전체 선발지수" />
</SelectTrigger>
<SelectContent>
<SelectItem value="overall" className="text-sm font-medium"> </SelectItem>
{filters.isActive && (
<SelectItem value="overall" className="text-sm font-medium"> </SelectItem>
)}
{Object.entries(TRAIT_CATEGORIES).map(([category, traits]) => (
<div key={category}>
<div className="px-2 py-1.5 text-sm font-semibold text-slate-500 bg-slate-50">{category}</div>

View File

@@ -8,10 +8,170 @@ import { Badge } from "@/components/ui/badge"
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 } from "lucide-react"
import { Settings2, Filter, X, Search, ChevronDown, ChevronUp, Loader2, Plus, Pin, GripVertical } from "lucide-react"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
import { DEFAULT_FILTER_SETTINGS } from "@/types/filter.types"
import { geneApi, type MarkerModel } from "@/lib/api/gene.api"
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
// 드래그 가능한 유전자 아이템 컴포넌트
function SortableGeneItem({
id,
isPinned,
onTogglePin,
onRemove,
}: {
id: string
isPinned: boolean
onTogglePin: () => void
onRemove: () => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-2 p-2 rounded-lg border ${isPinned ? 'bg-amber-50 border-amber-200' : 'bg-white border-slate-200'}`}
>
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 hover:bg-slate-100 rounded"
>
<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 flex-1">{id}</span>
<button
onClick={onTogglePin}
className={`p-1 rounded hover:bg-slate-100 ${isPinned ? 'text-amber-500' : 'text-slate-400'}`}
title={isPinned ? '고정 해제' : '고정'}
>
<Pin className="w-4 h-4" fill={isPinned ? 'currentColor' : 'none'} />
</button>
<button
onClick={onRemove}
className="p-1 rounded hover:bg-slate-100 text-slate-400 hover:text-red-500"
>
<X className="w-4 h-4" />
</button>
</div>
)
}
// 드래그 가능한 형질 아이템 컴포넌트
function SortableTraitItem({
id,
isPinned,
weight,
onTogglePin,
onRemove,
onWeightChange,
}: {
id: string
isPinned: boolean
weight: number
onTogglePin: () => void
onRemove: () => void
onWeightChange: (delta: number) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-2 p-2 rounded-lg border ${isPinned ? 'bg-amber-50 border-amber-200' : 'bg-white border-slate-200'}`}
>
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 hover:bg-slate-100 rounded"
>
<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>
<div className="flex items-center gap-1.5 ml-auto">
<Button
variant="outline"
size="sm"
className="h-6 w-6 p-0"
onClick={() => onWeightChange(-1)}
disabled={weight <= 0}
>
-
</Button>
<span className="text-xs font-bold w-8 text-center">{weight}</span>
<Button
variant="outline"
size="sm"
className="h-6 w-6 p-0"
onClick={() => onWeightChange(1)}
disabled={weight >= 10}
>
+
</Button>
</div>
<button
onClick={onTogglePin}
className={`p-1 rounded hover:bg-slate-100 ${isPinned ? 'text-amber-500' : 'text-slate-400'}`}
title={isPinned ? '고정 해제' : '고정'}
>
<Pin className="w-4 h-4" fill={isPinned ? 'currentColor' : 'none'} />
</button>
<button
onClick={onRemove}
className="p-1 rounded hover:bg-slate-100 text-slate-400 hover:text-red-500"
>
<X className="w-4 h-4" />
</button>
</div>
)
}
// 형질 카테고리 정의
const TRAIT_CATEGORIES = [
@@ -162,14 +322,24 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
}))
}
// 유전자 고정 토글
// 유전자 고정 토글 (고정 시 고정된 항목들 맨 아래로 이동)
const toggleGenePin = (markerNm: string) => {
setLocalFilters(prev => {
const pinnedGenes = prev.pinnedGenes || []
if (pinnedGenes.includes(markerNm)) {
// 고정 해제
return { ...prev, pinnedGenes: pinnedGenes.filter(g => g !== markerNm) }
} else {
return { ...prev, pinnedGenes: [...pinnedGenes, markerNm] }
// 고정 + 고정된 항목들 중 맨 아래로 이동
const newPinnedGenes = [...pinnedGenes, markerNm]
const unpinnedGenes = prev.selectedGenes.filter(g => !newPinnedGenes.includes(g))
const pinnedInOrder = prev.selectedGenes.filter(g => pinnedGenes.includes(g))
const newSelectedGenes = [...pinnedInOrder, markerNm, ...unpinnedGenes]
return {
...prev,
pinnedGenes: newPinnedGenes,
selectedGenes: newSelectedGenes
}
}
})
}
@@ -201,14 +371,25 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
})
}
// 형질 고정 토글
// 형질 고정 토글 (고정 시 고정된 항목들 맨 아래로 이동)
const toggleTraitPin = (traitName: string) => {
setLocalFilters(prev => {
const pinnedTraits = prev.pinnedTraits || []
const selectedTraits = prev.selectedTraits || []
if (pinnedTraits.includes(traitName)) {
// 고정 해제
return { ...prev, pinnedTraits: pinnedTraits.filter(t => t !== traitName) }
} else {
return { ...prev, pinnedTraits: [...pinnedTraits, traitName] }
// 고정 + 고정된 항목들 중 맨 아래로 이동
const newPinnedTraits = [...pinnedTraits, traitName]
const unpinnedTraits = selectedTraits.filter(t => !newPinnedTraits.includes(t))
const pinnedInOrder = selectedTraits.filter(t => pinnedTraits.includes(t))
const newSelectedTraits = [...pinnedInOrder, traitName, ...unpinnedTraits]
return {
...prev,
pinnedTraits: newPinnedTraits,
selectedTraits: newSelectedTraits
}
}
})
}
@@ -272,15 +453,44 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
})
}
// 정렬: 고정된 것 먼저
const sortedSelectedGenes = [
...(localFilters.pinnedGenes?.filter(g => localFilters.selectedGenes.includes(g)) || []),
...localFilters.selectedGenes.filter(g => !localFilters.pinnedGenes?.includes(g))
]
const sortedSelectedTraits = [
...(localFilters.pinnedTraits?.filter(t => localFilters.selectedTraits?.includes(t)) || []),
...(localFilters.selectedTraits?.filter(t => !localFilters.pinnedTraits?.includes(t)) || [])
]
// 드래그 앤 드롭 센서
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
// 유전자 순서 변경
const handleGeneDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
setLocalFilters(prev => {
const oldIndex = prev.selectedGenes.indexOf(active.id as string)
const newIndex = prev.selectedGenes.indexOf(over.id as string)
return {
...prev,
selectedGenes: arrayMove(prev.selectedGenes, oldIndex, newIndex)
}
})
}
}
// 형질 순서 변경
const handleTraitDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
setLocalFilters(prev => {
const selectedTraits = prev.selectedTraits || []
const oldIndex = selectedTraits.indexOf(active.id as string)
const newIndex = selectedTraits.indexOf(over.id as string)
return {
...prev,
selectedTraits: arrayMove(selectedTraits, oldIndex, newIndex)
}
})
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
@@ -354,104 +564,62 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
</h4>
{/* 유전자 */}
{sortedSelectedGenes.length > 0 && (
{localFilters.selectedGenes.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium"></div>
<div className="flex flex-wrap gap-2">
{sortedSelectedGenes.map(geneId => {
const isPinned = localFilters.pinnedGenes?.includes(geneId)
return (
<div
key={geneId}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-sm font-medium bg-slate-100 text-slate-700 border border-slate-200"
>
{isPinned && <Pin className="w-4 h-4" fill="currentColor" />}
<span>{geneId}</span>
<button
onClick={() => toggleGenePin(geneId)}
className="p-0.5 rounded hover:bg-black/10"
title={isPinned ? '고정 해제' : '고정'}
>
<Pin className="w-4 h-4" fill={isPinned ? 'currentColor' : 'none'} />
</button>
<button
onClick={() => toggleGene(geneId)}
className="p-0.5 rounded hover:bg-black/10"
>
<X className="w-4 h-4" />
</button>
</div>
)
})}
</div>
<div className="text-sm font-medium text-slate-600"></div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleGeneDragEnd}
>
<SortableContext
items={localFilters.selectedGenes}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{localFilters.selectedGenes.map(geneId => (
<SortableGeneItem
key={geneId}
id={geneId}
isPinned={localFilters.pinnedGenes?.includes(geneId) || false}
onTogglePin={() => toggleGenePin(geneId)}
onRemove={() => toggleGene(geneId)}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
)}
{/* 형질 */}
{sortedSelectedTraits.length > 0 && (
{(localFilters.selectedTraits?.length || 0) > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium"> </div>
<div className="flex flex-wrap gap-2">
{sortedSelectedTraits.map(traitId => {
const isPinned = localFilters.pinnedTraits?.includes(traitId)
return (
<div
key={traitId}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-sm font-medium bg-slate-100 text-slate-700 border border-slate-200"
>
{isPinned && <Pin className="w-4 h-4" fill="currentColor" />}
<span>{traitId}</span>
<span className="text-xs opacity-70">({localFilters.traitWeights[traitId as TraitName] || 0})</span>
<button
onClick={() => toggleTraitPin(traitId)}
className="p-0.5 rounded hover:bg-black/10"
title={isPinned ? '고정 해제' : '고정'}
>
<Pin className="w-4 h-4" fill={isPinned ? 'currentColor' : 'none'} />
</button>
<button
onClick={() => toggleTrait(traitId)}
className="p-0.5 rounded hover:bg-black/10"
>
<X className="w-4 h-4" />
</button>
</div>
)
})}
</div>
{/* 가중치 조절 */}
<div className="mt-3 p-3 bg-white rounded-lg border space-y-2">
<div className="text-xs font-semibold text-slate-600"> </div>
{sortedSelectedTraits.map(traitId => (
<div key={traitId} className="flex items-center justify-between">
<span className="text-sm font-medium truncate flex-1">{traitId}</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
onClick={() => updateTraitWeight(traitId, -1)}
disabled={(localFilters.traitWeights[traitId as TraitName] || 0) <= 0}
>
-
</Button>
<span className="text-sm font-bold w-8 text-center">
{localFilters.traitWeights[traitId as TraitName] || 0}
</span>
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
onClick={() => updateTraitWeight(traitId, 1)}
disabled={(localFilters.traitWeights[traitId as TraitName] || 0) >= 10}
>
+
</Button>
</div>
<div className="text-sm font-medium text-slate-600"> </div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleTraitDragEnd}
>
<SortableContext
items={localFilters.selectedTraits || []}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{(localFilters.selectedTraits || []).map(traitId => (
<SortableTraitItem
key={traitId}
id={traitId}
isPinned={localFilters.pinnedTraits?.includes(traitId) || false}
weight={localFilters.traitWeights[traitId as TraitName] || 0}
onTogglePin={() => toggleTraitPin(traitId)}
onRemove={() => toggleTrait(traitId)}
onWeightChange={(delta) => updateTraitWeight(traitId, delta)}
/>
))}
</div>
))}
</div>
</SortableContext>
</DndContext>
</div>
)}
</div>
@@ -484,15 +652,39 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
</div>
<div className="p-4 space-y-3 flex-1 overflow-y-auto">
{/* 검색창 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="유전자 검색..."
className="pl-9"
value={geneSearch}
onChange={(e) => setGeneSearch(e.target.value)}
/>
{/* 검색창 + 전체 선택/해제 버튼 */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="유전자 검색..."
className="pl-9"
value={geneSearch}
onChange={(e) => setGeneSearch(e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
className="whitespace-nowrap text-xs"
onClick={() => {
const allGenes = [...quantityGenes, ...qualityGenes]
toggleCategoryGenes(allGenes, true)
}}
>
</Button>
<Button
variant="outline"
size="sm"
className="whitespace-nowrap text-xs"
onClick={() => {
const allGenes = [...quantityGenes, ...qualityGenes]
toggleCategoryGenes(allGenes, false)
}}
>
</Button>
</div>
{loadingGenes ? (
@@ -685,15 +877,39 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
</div>
<div className="p-4 space-y-3 flex-1 overflow-y-auto">
{/* 검색창 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="형질 검색..."
className="pl-9"
value={traitSearch}
onChange={(e) => setTraitSearch(e.target.value)}
/>
{/* 검색창 + 전체 선택/해제 버튼 */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="형질 검색..."
className="pl-9"
value={traitSearch}
onChange={(e) => setTraitSearch(e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
className="whitespace-nowrap text-xs"
onClick={() => {
const allTraits = TRAIT_CATEGORIES.flatMap(cat => cat.traits)
toggleCategoryTraits(allTraits, true)
}}
>
</Button>
<Button
variant="outline"
size="sm"
className="whitespace-nowrap text-xs"
onClick={() => {
const allTraits = TRAIT_CATEGORIES.flatMap(cat => cat.traits)
toggleCategoryTraits(allTraits, false)
}}
>
</Button>
</div>
{/* 형질 목록 - 카테고리별 아코디언 */}