필터 UI 수정 및 대시보드 연동
This commit is contained in:
@@ -552,14 +552,22 @@ export class GenomeService {
|
||||
farms.sort((a, b) => b.avgEbv - a.avgEbv);
|
||||
}
|
||||
|
||||
// 형질별 평균 및 순위 계산
|
||||
// 형질별 평균 및 순위 계산 (표준 경쟁 순위 방식: 동률 시 같은 순위, 다음 순위 건너뜀)
|
||||
const traitAverages = Array.from(traitDataMap.entries()).map(([traitName, data]) => {
|
||||
const avgEbv = Math.round((data.sum / data.count) * 100) / 100;
|
||||
const avgEpd = Math.round((data.epdSum / data.count) * 100) / 100; // 육종가(EPD) 평균
|
||||
const rankings = traitRankingMap.get(traitName) || [];
|
||||
const totalFarms = rankings.length;
|
||||
const rankIndex = rankings.findIndex(r => r.farmNo === farmNo);
|
||||
const rank = rankIndex >= 0 ? rankIndex + 1 : null;
|
||||
|
||||
// 표준 경쟁 순위 계산: 동률 처리
|
||||
let rank: number | null = null;
|
||||
const farmData = rankings.find(r => r.farmNo === farmNo);
|
||||
if (farmData) {
|
||||
// 나보다 높은 점수를 가진 농장 수 + 1 = 내 순위
|
||||
const higherCount = rankings.filter(r => r.avgEbv > farmData.avgEbv).length;
|
||||
rank = higherCount + 1;
|
||||
}
|
||||
|
||||
const percentile = rank !== null && totalFarms > 0 ? Math.round((rank / totalFarms) * 100) : null;
|
||||
|
||||
return {
|
||||
@@ -1793,9 +1801,14 @@ export class GenomeService {
|
||||
// 내림차순 정렬
|
||||
farmAverages.sort((a, b) => b.avgScore - a.avgScore);
|
||||
|
||||
// 8. 현재 농가 순위 찾기
|
||||
const myFarmIndex = farmAverages.findIndex(f => f.farmNo === farmNo);
|
||||
// 8. 현재 농가 순위 찾기 (표준 경쟁 순위: 동률 시 같은 순위)
|
||||
const myFarmData = farmAverages.find(f => f.farmNo === farmNo);
|
||||
let farmRankInRegion: number | null = null;
|
||||
if (myFarmData) {
|
||||
// 나보다 높은 점수를 가진 농장 수 + 1 = 내 순위
|
||||
const higherCount = farmAverages.filter(f => f.avgScore > myFarmData.avgScore).length;
|
||||
farmRankInRegion = higherCount + 1;
|
||||
}
|
||||
|
||||
// 9. 보은군 전체 평균
|
||||
const regionAvgScore = allScores.length > 0
|
||||
@@ -1807,10 +1820,10 @@ export class GenomeService {
|
||||
farmerName: farm.farmerName || null,
|
||||
farmAvgScore: myFarmData?.avgScore ?? null,
|
||||
regionAvgScore,
|
||||
farmRankInRegion: myFarmIndex >= 0 ? myFarmIndex + 1 : null,
|
||||
farmRankInRegion,
|
||||
totalFarmsInRegion: farmAverages.length,
|
||||
percentile: myFarmIndex >= 0 && farmAverages.length > 0
|
||||
? Math.round(((myFarmIndex + 1) / farmAverages.length) * 100)
|
||||
percentile: farmRankInRegion !== null && farmAverages.length > 0
|
||||
? Math.round((farmRankInRegion / farmAverages.length) * 100)
|
||||
: null,
|
||||
farmCowCount: myFarmData?.cowCount || 0,
|
||||
regionCowCount: allScores.length,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 형질 목록 - 카테고리별 아코디언 */}
|
||||
|
||||
Reference in New Issue
Block a user