diff --git a/frontend/src/app/demo/global-filter/page.tsx b/frontend/src/app/demo/global-filter/page.tsx new file mode 100644 index 0000000..c89547c --- /dev/null +++ b/frontend/src/app/demo/global-filter/page.tsx @@ -0,0 +1,2906 @@ +'use client' + +import { useState } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { Checkbox } from "@/components/ui/checkbox" +import { Slider } from "@/components/ui/slider" +import { + Search, + ChevronDown, + ChevronUp, + X, + Filter, + Dna, + BarChart3, + Pin, + GripVertical, + ChevronLeft, + ChevronRight, + Settings, + Plus +} from 'lucide-react' + +// 샘플 유전자 데이터 (실제 전역필터와 동일한 구조) +const SAMPLE_GENES = { + quantity: [ + { id: 'PLAG1', name: 'PLAG1', description: '성장 및 체중 증가' }, + { id: 'NCAPG2', name: 'NCAPG2', description: '체구 크기와 성장 속도' }, + { id: 'LCORL', name: 'LCORL', description: '체형 및 성장' }, + { id: 'ARRDC3', name: 'ARRDC3', description: '체중 증가' }, + { id: 'LAP3', name: 'LAP3', description: '성장 속도' }, + ], + quality: [ + { id: 'NT5E', name: 'NT5E', description: '근내지방도' }, + { id: 'SCD', name: 'SCD', description: '지방산 불포화도' }, + { id: 'FASN', name: 'FASN', description: '지방 합성' }, + { id: 'CAPN1', name: 'CAPN1', description: '육질 연도' }, + { id: 'CAST', name: 'CAST', description: '육질 연도 억제' }, + { id: 'DGAT1', name: 'DGAT1', description: '지방 함량' }, + ] +} + +// 샘플 형질 데이터 (실제 전역필터와 동일한 구조) +const SAMPLE_TRAITS = { + growth: [ + { id: '12개월령체중', name: '12개월령체중', description: '12개월 시점 체중' }, + ], + economic: [ + { id: '도체중', name: '도체중', description: '도축 후 고기 무게' }, + { id: '등심단면적', name: '등심단면적', description: '등심의 단면 크기' }, + { id: '등지방두께', name: '등지방두께', description: '등 부위 지방 두께' }, + { id: '근내지방도', name: '근내지방도', description: '마블링 정도' }, + ], + body: [ + { id: '체고', name: '체고', description: '어깨 높이' }, + { id: '십자', name: '십자', description: '엉덩이뼈 높이' }, + { id: '체장', name: '체장', description: '몸통 길이' }, + { id: '흉심', name: '흉심', description: '가슴 깊이' }, + { id: '흉폭', name: '흉폭', description: '가슴 너비' }, + { id: '고장', name: '고장', description: '허리뼈 길이' }, + { id: '요각폭', name: '요각폭', description: '허리뼈 너비' }, + { id: '좌골폭', name: '좌골폭', description: '엉덩이뼈 너비' }, + { id: '곤폭', name: '곤폭', description: '엉덩이뼈 끝 너비' }, + { id: '흉위', name: '흉위', description: '가슴 둘레' }, + ], + weight: [ + { id: '안심weight', name: '안심', description: '안심 부위 무게' }, + { id: '등심weight', name: '등심', description: '등심 부위 무게' }, + { id: '채끝weight', name: '채끝', description: '채끝 부위 무게' }, + { id: '목심weight', name: '목심', description: '목심 부위 무게' }, + { id: '앞다리weight', name: '앞다리', description: '앞다리 부위 무게' }, + { id: '우둔weight', name: '우둔', description: '우둔 부위 무게' }, + { id: '설도weight', name: '설도', description: '설도 부위 무게' }, + { id: '사태weight', name: '사태', description: '사태 부위 무게' }, + { id: '양지weight', name: '양지', description: '양지 부위 무게' }, + { id: '갈비weight', name: '갈비', description: '갈비 부위 무게' }, + ], + rate: [ + { id: '안심rate', name: '안심', description: '전체 대비 안심 비율' }, + { id: '등심rate', name: '등심', description: '전체 대비 등심 비율' }, + { id: '채끝rate', name: '채끝', description: '전체 대비 채끝 비율' }, + { id: '목심rate', name: '목심', description: '전체 대비 목심 비율' }, + { id: '앞다리rate', name: '앞다리', description: '전체 대비 앞다리 비율' }, + { id: '우둔rate', name: '우둔', description: '전체 대비 우둔 비율' }, + { id: '설도rate', name: '설도', description: '전체 대비 설도 비율' }, + { id: '사태rate', name: '사태', description: '전체 대비 사태 비율' }, + { id: '양지rate', name: '양지', description: '전체 대비 양지 비율' }, + { id: '갈비rate', name: '갈비', description: '전체 대비 갈비 비율' }, + ], +} + +export default function GlobalFilterDemo() { + const [activeTab, setActiveTab] = useState('option-a') + + return ( +
+
+

전역 필터 UI 시안 데모

+

+ 7가지 시안을 비교해보세요. 각 탭을 눌러 다른 UI 방식을 확인할 수 있습니다. +

+

+ ✓ 모든 시안에 고정(Pin) 기능과 드래그 순서 변경 기능이 포함되어 있습니다. +

+ + + + A: 드롭다운 + B: 통합검색 + C: 현재방식 + D: 스텝 + E: 탭분리 + F: 모달선택 + H: 칩기반 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ) +} + +// 공통 드래그 가능한 아이템 컴포넌트 +function DraggableItem({ + id, + label, + isPinned, + onTogglePin, + onRemove, + weight, + onWeightChange, + showWeight = false +}: { + id: string + label: string + isPinned: boolean + onTogglePin: () => void + onRemove: () => void + weight?: number + onWeightChange?: (value: number) => void + showWeight?: boolean +}) { + return ( +
+ + {label} + {showWeight && weight !== undefined && onWeightChange && ( +
+ onWeightChange(v)} + max={10} + step={1} + className="w-20" + /> + {weight}점 +
+ )} + + +
+ ) +} + +/** + * 시안 A: 드롭다운 방식 + * - 선택한 필터 조건 박스 안에서 "유전자를 선택해주세요" 클릭 시 드롭다운 + * - 드롭다운 내에서 고정 영역 + 검색 + 목록 + */ +function OptionA() { + const [selectedGenes, setSelectedGenes] = useState(['PLAG1', 'NT5E', 'SCD']) + const [pinnedGenes, setPinnedGenes] = useState(['PLAG1', 'NT5E']) + const [selectedTraits, setSelectedTraits] = useState(['도체중', '등심단면적']) + const [pinnedTraits, setPinnedTraits] = useState(['도체중']) + const [traitWeights, setTraitWeights] = useState>({ '도체중': 7, '등심단면적': 5 }) + const [geneDropdownOpen, setGeneDropdownOpen] = useState(false) + const [traitDropdownOpen, setTraitDropdownOpen] = useState(false) + const [geneSearch, setGeneSearch] = useState('') + const [traitSearch, setTraitSearch] = useState('') + + const toggleGene = (geneId: string) => { + if (selectedGenes.includes(geneId)) { + setSelectedGenes(prev => prev.filter(g => g !== geneId)) + setPinnedGenes(prev => prev.filter(g => g !== geneId)) + } else { + setSelectedGenes(prev => [...prev, geneId]) + } + } + + const toggleGenePin = (geneId: string) => { + if (pinnedGenes.includes(geneId)) { + setPinnedGenes(prev => prev.filter(g => g !== geneId)) + } else if (pinnedGenes.length < 5) { + setPinnedGenes(prev => [...prev, geneId]) + } + } + + const toggleTrait = (traitId: string) => { + if (selectedTraits.includes(traitId)) { + setSelectedTraits(prev => prev.filter(t => t !== traitId)) + setPinnedTraits(prev => prev.filter(t => t !== traitId)) + setTraitWeights(prev => { + const newWeights = { ...prev } + delete newWeights[traitId] + return newWeights + }) + } else { + setSelectedTraits(prev => [...prev, traitId]) + setTraitWeights(prev => ({ ...prev, [traitId]: 5 })) + } + } + + const toggleTraitPin = (traitId: string) => { + if (pinnedTraits.includes(traitId)) { + setPinnedTraits(prev => prev.filter(t => t !== traitId)) + } else if (pinnedTraits.length < 5) { + setPinnedTraits(prev => [...prev, traitId]) + } + } + + const allGenes = [...SAMPLE_GENES.quantity, ...SAMPLE_GENES.quality] + const allTraits = [...SAMPLE_TRAITS.economic, ...SAMPLE_TRAITS.growth, ...SAMPLE_TRAITS.body] + + const filteredGenes = allGenes.filter(g => + g.name.toLowerCase().includes(geneSearch.toLowerCase()) || + g.description.toLowerCase().includes(geneSearch.toLowerCase()) + ) + + const filteredTraits = allTraits.filter(t => + t.name.toLowerCase().includes(traitSearch.toLowerCase()) || + t.description.toLowerCase().includes(traitSearch.toLowerCase()) + ) + + // 정렬: 고정된 것 먼저, 그 다음 일반 + const sortedSelectedGenes = [ + ...pinnedGenes.filter(g => selectedGenes.includes(g)), + ...selectedGenes.filter(g => !pinnedGenes.includes(g)) + ] + + const sortedSelectedTraits = [ + ...pinnedTraits.filter(t => selectedTraits.includes(t)), + ...selectedTraits.filter(t => !pinnedTraits.includes(t)) + ] + + return ( + + + + + 시안 A: 드롭다운 방식 + +

+ 선택 영역 터치 → 드롭다운 펼침 → 고정 영역 + 검색 + 목록 +

+
+ +
+

선택한 필터 조건

+ + {/* 유전자 드롭다운 */} + + +
+
+ + 유전자 + {selectedGenes.length > 0 && ( + {selectedGenes.length}개 + )} + {pinnedGenes.length > 0 && ( + + {pinnedGenes.length} + + )} +
+ {geneDropdownOpen ? : } +
+
+ +
+ {/* 고정된 유전자 */} + {sortedSelectedGenes.length > 0 && ( +
+
+ + 고정됨 (드래그로 순서 변경) +
+ {sortedSelectedGenes.filter(g => pinnedGenes.includes(g)).map(geneId => ( + toggleGenePin(geneId)} + onRemove={() => toggleGene(geneId)} + /> + ))} + {sortedSelectedGenes.filter(g => !pinnedGenes.includes(g)).length > 0 && ( + <> +
일반
+ {sortedSelectedGenes.filter(g => !pinnedGenes.includes(g)).map(geneId => ( + toggleGenePin(geneId)} + onRemove={() => toggleGene(geneId)} + /> + ))} + + )} +
+ )} + + {/* 검색창 */} +
+ + setGeneSearch(e.target.value)} + /> +
+ + {/* 유전자 목록 */} +
+
육량형
+ {filteredGenes.filter(g => SAMPLE_GENES.quantity.some(q => q.id === g.id)).map(gene => ( +
toggleGene(gene.id)} + > +
+ + {gene.name} + {gene.description} +
+ {selectedGenes.includes(gene.id) && ( + + )} +
+ ))} +
육질형
+ {filteredGenes.filter(g => SAMPLE_GENES.quality.some(q => q.id === g.id)).map(gene => ( +
toggleGene(gene.id)} + > +
+ + {gene.name} + {gene.description} +
+ {selectedGenes.includes(gene.id) && ( + + )} +
+ ))} +
+
+
+
+ + {/* 형질 드롭다운 */} + + +
+
+ + 형질 + {selectedTraits.length > 0 && ( + {selectedTraits.length}개 + )} + {pinnedTraits.length > 0 && ( + + {pinnedTraits.length} + + )} +
+ {traitDropdownOpen ? : } +
+
+ +
+ {/* 선택된 형질 + 가중치 */} + {sortedSelectedTraits.length > 0 && ( +
+
+ + 고정됨 (드래그로 순서 변경) +
+ {sortedSelectedTraits.filter(t => pinnedTraits.includes(t)).map(traitId => ( + toggleTraitPin(traitId)} + onRemove={() => toggleTrait(traitId)} + showWeight={true} + weight={traitWeights[traitId]} + onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))} + /> + ))} + {sortedSelectedTraits.filter(t => !pinnedTraits.includes(t)).length > 0 && ( + <> +
일반
+ {sortedSelectedTraits.filter(t => !pinnedTraits.includes(t)).map(traitId => ( + toggleTraitPin(traitId)} + onRemove={() => toggleTrait(traitId)} + showWeight={true} + weight={traitWeights[traitId]} + onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))} + /> + ))} + + )} +
+ )} + + {/* 검색창 */} +
+ + setTraitSearch(e.target.value)} + /> +
+ + {/* 형질 목록 */} +
+
경제형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.economic.some(e => e.id === t.id)).map(trait => ( +
toggleTrait(trait.id)} + > +
+ + {trait.name} + {trait.description} +
+ {selectedTraits.includes(trait.id) && ( + + )} +
+ ))} +
+
+
+
+
+ +
+ + +
+
+
+ ) +} + +/** + * 시안 B: 통합검색 + 결과 바로 아래 + 하단 선택조건 + */ +function OptionB() { + const [selectedGenes, setSelectedGenes] = useState(['PLAG1', 'NT5E', 'SCD']) + const [pinnedGenes, setPinnedGenes] = useState(['PLAG1', 'NT5E']) + const [selectedTraits, setSelectedTraits] = useState(['도체중', '등심단면적']) + const [pinnedTraits, setPinnedTraits] = useState(['도체중']) + const [traitWeights, setTraitWeights] = useState>({ '도체중': 7, '등심단면적': 5 }) + const [searchQuery, setSearchQuery] = useState('') + const [quantityOpen, setQuantityOpen] = useState(false) + const [qualityOpen, setQualityOpen] = useState(false) + const [economicOpen, setEconomicOpen] = useState(false) + + const toggleGene = (geneId: string) => { + if (selectedGenes.includes(geneId)) { + setSelectedGenes(prev => prev.filter(g => g !== geneId)) + setPinnedGenes(prev => prev.filter(g => g !== geneId)) + } else { + setSelectedGenes(prev => [...prev, geneId]) + } + } + + const toggleGenePin = (geneId: string) => { + if (pinnedGenes.includes(geneId)) { + setPinnedGenes(prev => prev.filter(g => g !== geneId)) + } else if (pinnedGenes.length < 5) { + setPinnedGenes(prev => [...prev, geneId]) + } + } + + const toggleTrait = (traitId: string) => { + if (selectedTraits.includes(traitId)) { + setSelectedTraits(prev => prev.filter(t => t !== traitId)) + setPinnedTraits(prev => prev.filter(t => t !== traitId)) + setTraitWeights(prev => { + const newWeights = { ...prev } + delete newWeights[traitId] + return newWeights + }) + } else { + setSelectedTraits(prev => [...prev, traitId]) + setTraitWeights(prev => ({ ...prev, [traitId]: 5 })) + } + } + + const toggleTraitPin = (traitId: string) => { + if (pinnedTraits.includes(traitId)) { + setPinnedTraits(prev => prev.filter(t => t !== traitId)) + } else if (pinnedTraits.length < 5) { + setPinnedTraits(prev => [...prev, traitId]) + } + } + + // 검색 필터링 + const filteredQuantityGenes = SAMPLE_GENES.quantity.filter(g => + !searchQuery || g.name.toLowerCase().includes(searchQuery.toLowerCase()) || + g.description.toLowerCase().includes(searchQuery.toLowerCase()) || + '육량형'.includes(searchQuery) + ) + const filteredQualityGenes = SAMPLE_GENES.quality.filter(g => + !searchQuery || g.name.toLowerCase().includes(searchQuery.toLowerCase()) || + g.description.toLowerCase().includes(searchQuery.toLowerCase()) || + '육질형'.includes(searchQuery) + ) + const filteredEconomicTraits = SAMPLE_TRAITS.economic.filter(t => + !searchQuery || t.name.toLowerCase().includes(searchQuery.toLowerCase()) || + t.description.toLowerCase().includes(searchQuery.toLowerCase()) || + '경제형질'.includes(searchQuery) + ) + + const shouldShowQuantity = !searchQuery || filteredQuantityGenes.length > 0 + const shouldShowQuality = !searchQuery || filteredQualityGenes.length > 0 + const shouldShowEconomic = !searchQuery || filteredEconomicTraits.length > 0 + + // 정렬 + const sortedSelectedGenes = [ + ...pinnedGenes.filter(g => selectedGenes.includes(g)), + ...selectedGenes.filter(g => !pinnedGenes.includes(g)) + ] + const sortedSelectedTraits = [ + ...pinnedTraits.filter(t => selectedTraits.includes(t)), + ...selectedTraits.filter(t => !pinnedTraits.includes(t)) + ] + + return ( + + + + + 시안 B: 통합검색 + 하단 선택조건 (권장) + +

+ 통합검색 → 바로 아래 결과 → 맨 아래서 선택 조건 및 고정/순서 관리 +

+
+ + {/* 통합 검색창 */} +
+ + setSearchQuery(e.target.value)} + /> +
+ + {/* 유전자 섹션 */} +
+

+ + 유전자 선택 +

+ + {shouldShowQuantity && ( + + +
+ 육량형 +
+ {filteredQuantityGenes.length}개 + {(quantityOpen || !!searchQuery) ? : } +
+
+
+ + {filteredQuantityGenes.map(gene => ( +
toggleGene(gene.id)} + > +
+ + {gene.name} + {gene.description} +
+ {selectedGenes.includes(gene.id) && ( + + )} +
+ ))} +
+
+ )} + + {shouldShowQuality && ( + + +
+ 육질형 +
+ {filteredQualityGenes.length}개 + {(qualityOpen || !!searchQuery) ? : } +
+
+
+ + {filteredQualityGenes.map(gene => ( +
toggleGene(gene.id)} + > +
+ + {gene.name} + {gene.description} +
+ {selectedGenes.includes(gene.id) && ( + + )} +
+ ))} +
+
+ )} +
+ + {/* 형질 섹션 */} +
+

+ + 형질 가중치 설정 +

+ + {shouldShowEconomic && ( + + +
+ 경제형질 +
+ {filteredEconomicTraits.length}개 + {(economicOpen || !!searchQuery) ? : } +
+
+
+ + {filteredEconomicTraits.map(trait => ( +
+
toggleTrait(trait.id)} + > +
+ + {trait.name} + {trait.description} +
+ {selectedTraits.includes(trait.id) && ( + + )} +
+ {selectedTraits.includes(trait.id) && ( +
+ setTraitWeights(prev => ({ ...prev, [trait.id]: v }))} + max={10} + step={1} + className="flex-1" + /> + {traitWeights[trait.id] || 0}점 +
+ )} +
+ ))} +
+
+ )} +
+ + {/* 선택한 필터 조건 (하단) */} +
+

+ 선택한 필터 조건 + {selectedGenes.length + selectedTraits.length}개 +

+ + {/* 유전자 */} + {sortedSelectedGenes.length > 0 && ( +
+
+ + 유전자 +
+
+ {sortedSelectedGenes.map(geneId => ( + toggleGenePin(geneId)} + onRemove={() => toggleGene(geneId)} + /> + ))} +
+
+ )} + + {/* 형질 */} + {sortedSelectedTraits.length > 0 && ( +
+
+ + 형질 +
+
+ {sortedSelectedTraits.map(traitId => ( + toggleTraitPin(traitId)} + onRemove={() => toggleTrait(traitId)} + showWeight={true} + weight={traitWeights[traitId]} + onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))} + /> + ))} +
+
+ )} +
+ +
+ + +
+
+
+ ) +} + +/** + * 시안 C: 현재 방식 (선택조건 상단 고정) + */ +function OptionC() { + const [selectedGenes, setSelectedGenes] = useState(['PLAG1', 'NT5E', 'SCD']) + const [pinnedGenes, setPinnedGenes] = useState(['PLAG1', 'NT5E']) + const [selectedTraits, setSelectedTraits] = useState(['도체중', '등심단면적']) + const [pinnedTraits, setPinnedTraits] = useState(['도체중']) + const [traitWeights, setTraitWeights] = useState>({ '도체중': 7, '등심단면적': 5 }) + const [searchQuery, setSearchQuery] = useState('') + const [filterConditionOpen, setFilterConditionOpen] = useState(true) + const [quantityOpen, setQuantityOpen] = useState(false) + const [qualityOpen, setQualityOpen] = useState(false) + const [economicOpen, setEconomicOpen] = useState(false) + + const toggleGene = (geneId: string) => { + if (selectedGenes.includes(geneId)) { + setSelectedGenes(prev => prev.filter(g => g !== geneId)) + setPinnedGenes(prev => prev.filter(g => g !== geneId)) + } else { + setSelectedGenes(prev => [...prev, geneId]) + } + } + + const toggleGenePin = (geneId: string) => { + if (pinnedGenes.includes(geneId)) { + setPinnedGenes(prev => prev.filter(g => g !== geneId)) + } else if (pinnedGenes.length < 5) { + setPinnedGenes(prev => [...prev, geneId]) + } + } + + const toggleTrait = (traitId: string) => { + if (selectedTraits.includes(traitId)) { + setSelectedTraits(prev => prev.filter(t => t !== traitId)) + setPinnedTraits(prev => prev.filter(t => t !== traitId)) + setTraitWeights(prev => { + const newWeights = { ...prev } + delete newWeights[traitId] + return newWeights + }) + } else { + setSelectedTraits(prev => [...prev, traitId]) + setTraitWeights(prev => ({ ...prev, [traitId]: 5 })) + } + } + + const toggleTraitPin = (traitId: string) => { + if (pinnedTraits.includes(traitId)) { + setPinnedTraits(prev => prev.filter(t => t !== traitId)) + } else if (pinnedTraits.length < 5) { + setPinnedTraits(prev => [...prev, traitId]) + } + } + + const sortedSelectedGenes = [ + ...pinnedGenes.filter(g => selectedGenes.includes(g)), + ...selectedGenes.filter(g => !pinnedGenes.includes(g)) + ] + const sortedSelectedTraits = [ + ...pinnedTraits.filter(t => selectedTraits.includes(t)), + ...selectedTraits.filter(t => !pinnedTraits.includes(t)) + ] + + return ( + + + + + 시안 C: 현재 방식 (선택조건 상단) + +

+ 현재 구현된 방식 - 선택 조건이 검색창 위에 고정 (고정/순서 기능 추가) +

+
+ + {/* 선택한 필터 조건 (상단 고정) */} + + +
+ 선택한 필터 조건 +
+ {selectedGenes.length + selectedTraits.length}개 + {filterConditionOpen ? : } +
+
+
+ +
+ {/* 유전자 */} + {sortedSelectedGenes.length > 0 && ( +
+
+ + 유전자 +
+
+ {sortedSelectedGenes.map(geneId => ( + toggleGenePin(geneId)} + onRemove={() => toggleGene(geneId)} + /> + ))} +
+
+ )} + + {/* 형질 */} + {sortedSelectedTraits.length > 0 && ( +
+
+ + 형질 +
+
+ {sortedSelectedTraits.map(traitId => ( + toggleTraitPin(traitId)} + onRemove={() => toggleTrait(traitId)} + showWeight={true} + weight={traitWeights[traitId]} + onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))} + /> + ))} +
+
+ )} +
+
+
+ + {/* 통합 검색창 */} +
+ + setSearchQuery(e.target.value)} + /> +
+ + {/* 유전자 섹션 */} +
+

+ + 유전자 선택 +

+ + + +
+ 육량형 + {quantityOpen ? : } +
+
+ + {SAMPLE_GENES.quantity.map(gene => ( +
toggleGene(gene.id)} + > +
+ + {gene.name} + {gene.description} +
+ {selectedGenes.includes(gene.id) && ( + + )} +
+ ))} +
+
+ + + +
+ 육질형 + {qualityOpen ? : } +
+
+ + {SAMPLE_GENES.quality.map(gene => ( +
toggleGene(gene.id)} + > +
+ + {gene.name} + {gene.description} +
+ {selectedGenes.includes(gene.id) && ( + + )} +
+ ))} +
+
+
+ + {/* 형질 섹션 */} +
+

+ + 형질 가중치 설정 +

+ + + +
+ 경제형질 + {economicOpen ? : } +
+
+ + {SAMPLE_TRAITS.economic.map(trait => ( +
+
toggleTrait(trait.id)} + > +
+ + {trait.name} + {trait.description} +
+ {selectedTraits.includes(trait.id) && ( + + )} +
+ {selectedTraits.includes(trait.id) && ( +
+ setTraitWeights(prev => ({ ...prev, [trait.id]: v }))} + max={10} + step={1} + className="flex-1" + /> + {traitWeights[trait.id] || 0}점 +
+ )} +
+ ))} +
+
+
+ +
+ + +
+
+
+ ) +} + +/** + * 시안 D: 스텝(단계별) 방식 + */ +function OptionD() { + const [currentStep, setCurrentStep] = useState(1) + const [selectedGenes, setSelectedGenes] = useState(['PLAG1', 'NT5E', 'SCD']) + const [pinnedGenes, setPinnedGenes] = useState(['PLAG1', 'NT5E']) + const [selectedTraits, setSelectedTraits] = useState(['도체중', '등심단면적']) + const [pinnedTraits, setPinnedTraits] = useState(['도체중']) + const [traitWeights, setTraitWeights] = useState>({ '도체중': 7, '등심단면적': 5 }) + const [geneSearch, setGeneSearch] = useState('') + const [traitSearch, setTraitSearch] = useState('') + + const toggleGene = (geneId: string) => { + if (selectedGenes.includes(geneId)) { + setSelectedGenes(prev => prev.filter(g => g !== geneId)) + setPinnedGenes(prev => prev.filter(g => g !== geneId)) + } else { + setSelectedGenes(prev => [...prev, geneId]) + } + } + + const toggleGenePin = (geneId: string) => { + if (pinnedGenes.includes(geneId)) { + setPinnedGenes(prev => prev.filter(g => g !== geneId)) + } else if (pinnedGenes.length < 5) { + setPinnedGenes(prev => [...prev, geneId]) + } + } + + const toggleTrait = (traitId: string) => { + if (selectedTraits.includes(traitId)) { + setSelectedTraits(prev => prev.filter(t => t !== traitId)) + setPinnedTraits(prev => prev.filter(t => t !== traitId)) + setTraitWeights(prev => { + const newWeights = { ...prev } + delete newWeights[traitId] + return newWeights + }) + } else { + setSelectedTraits(prev => [...prev, traitId]) + setTraitWeights(prev => ({ ...prev, [traitId]: 5 })) + } + } + + const toggleTraitPin = (traitId: string) => { + if (pinnedTraits.includes(traitId)) { + setPinnedTraits(prev => prev.filter(t => t !== traitId)) + } else if (pinnedTraits.length < 5) { + setPinnedTraits(prev => [...prev, traitId]) + } + } + + const allGenes = [...SAMPLE_GENES.quantity, ...SAMPLE_GENES.quality] + const allTraits = [...SAMPLE_TRAITS.economic, ...SAMPLE_TRAITS.growth, ...SAMPLE_TRAITS.body] + + const filteredGenes = allGenes.filter(g => + g.name.toLowerCase().includes(geneSearch.toLowerCase()) || + g.description.toLowerCase().includes(geneSearch.toLowerCase()) + ) + + const filteredTraits = allTraits.filter(t => + t.name.toLowerCase().includes(traitSearch.toLowerCase()) || + t.description.toLowerCase().includes(traitSearch.toLowerCase()) + ) + + const sortedSelectedGenes = [ + ...pinnedGenes.filter(g => selectedGenes.includes(g)), + ...selectedGenes.filter(g => !pinnedGenes.includes(g)) + ] + const sortedSelectedTraits = [ + ...pinnedTraits.filter(t => selectedTraits.includes(t)), + ...selectedTraits.filter(t => !pinnedTraits.includes(t)) + ] + + return ( + + + + + 시안 D: 스텝(단계별) 방식 + +

+ Step 1: 유전자 선택 → Step 2: 형질 선택 → Step 3: 확인 +

+
+ + {/* 스텝 인디케이터 */} +
+ {[1, 2, 3].map((step) => ( +
+ + {step < 3 && ( +
step ? 'bg-blue-500' : 'bg-slate-200'}`} /> + )} +
+ ))} +
+
+ {currentStep === 1 && '유전자 선택'} + {currentStep === 2 && '형질 선택'} + {currentStep === 3 && '최종 확인'} +
+ + {/* Step 1: 유전자 선택 */} + {currentStep === 1 && ( +
+ {/* 선택된 유전자 (고정/순서) */} + {sortedSelectedGenes.length > 0 && ( +
+
선택됨 ({sortedSelectedGenes.length}개)
+ {sortedSelectedGenes.map(geneId => ( + toggleGenePin(geneId)} + onRemove={() => toggleGene(geneId)} + /> + ))} +
+ )} + + {/* 검색 */} +
+ + setGeneSearch(e.target.value)} + /> +
+ + {/* 유전자 목록 */} +
+
육량형
+ {filteredGenes.filter(g => SAMPLE_GENES.quantity.some(q => q.id === g.id)).map(gene => ( +
toggleGene(gene.id)} + > +
+ + {gene.name} + {gene.description} +
+
+ ))} +
육질형
+ {filteredGenes.filter(g => SAMPLE_GENES.quality.some(q => q.id === g.id)).map(gene => ( +
toggleGene(gene.id)} + > +
+ + {gene.name} + {gene.description} +
+
+ ))} +
+
+ )} + + {/* Step 2: 형질 선택 */} + {currentStep === 2 && ( +
+ {/* 선택된 형질 (고정/순서/가중치) */} + {sortedSelectedTraits.length > 0 && ( +
+
선택됨 ({sortedSelectedTraits.length}개)
+ {sortedSelectedTraits.map(traitId => ( + toggleTraitPin(traitId)} + onRemove={() => toggleTrait(traitId)} + showWeight={true} + weight={traitWeights[traitId]} + onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))} + /> + ))} +
+ )} + + {/* 검색 */} +
+ + setTraitSearch(e.target.value)} + /> +
+ + {/* 형질 목록 */} +
+
경제형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.economic.some(e => e.id === t.id)).map(trait => ( +
toggleTrait(trait.id)} + > +
+ + {trait.name} + {trait.description} +
+
+ ))} +
+
+ )} + + {/* Step 3: 최종 확인 */} + {currentStep === 3 && ( +
+
+

최종 선택 확인

+ + {/* 유전자 */} +
+
+ + 유전자 ({sortedSelectedGenes.length}개) +
+
+ {sortedSelectedGenes.map(geneId => ( + toggleGenePin(geneId)} + onRemove={() => toggleGene(geneId)} + /> + ))} +
+
+ + {/* 형질 */} +
+
+ + 형질 ({sortedSelectedTraits.length}개) +
+
+ {sortedSelectedTraits.map(traitId => ( + toggleTraitPin(traitId)} + onRemove={() => toggleTrait(traitId)} + showWeight={true} + weight={traitWeights[traitId]} + onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))} + /> + ))} +
+
+
+
+ )} + + {/* 네비게이션 버튼 */} +
+ {currentStep > 1 && ( + + )} +
+ {currentStep < 3 ? ( + + ) : ( + + )} +
+ + + ) +} + +/** + * 시안 E: 탭 분리 방식 + */ +function OptionE() { + const [innerTab, setInnerTab] = useState('genes') + const [selectedGenes, setSelectedGenes] = useState(['PLAG1', 'NT5E', 'SCD']) + const [pinnedGenes, setPinnedGenes] = useState(['PLAG1', 'NT5E']) + const [selectedTraits, setSelectedTraits] = useState(['도체중', '등심단면적']) + const [pinnedTraits, setPinnedTraits] = useState(['도체중']) + const [traitWeights, setTraitWeights] = useState>({ '도체중': 7, '등심단면적': 5 }) + const [geneSearch, setGeneSearch] = useState('') + const [traitSearch, setTraitSearch] = useState('') + + const toggleGene = (geneId: string) => { + if (selectedGenes.includes(geneId)) { + setSelectedGenes(prev => prev.filter(g => g !== geneId)) + setPinnedGenes(prev => prev.filter(g => g !== geneId)) + } else { + setSelectedGenes(prev => [...prev, geneId]) + } + } + + const toggleGenePin = (geneId: string) => { + if (pinnedGenes.includes(geneId)) { + setPinnedGenes(prev => prev.filter(g => g !== geneId)) + } else if (pinnedGenes.length < 5) { + setPinnedGenes(prev => [...prev, geneId]) + } + } + + const toggleTrait = (traitId: string) => { + if (selectedTraits.includes(traitId)) { + setSelectedTraits(prev => prev.filter(t => t !== traitId)) + setPinnedTraits(prev => prev.filter(t => t !== traitId)) + setTraitWeights(prev => { + const newWeights = { ...prev } + delete newWeights[traitId] + return newWeights + }) + } else { + setSelectedTraits(prev => [...prev, traitId]) + setTraitWeights(prev => ({ ...prev, [traitId]: 5 })) + } + } + + const toggleTraitPin = (traitId: string) => { + if (pinnedTraits.includes(traitId)) { + setPinnedTraits(prev => prev.filter(t => t !== traitId)) + } else if (pinnedTraits.length < 5) { + setPinnedTraits(prev => [...prev, traitId]) + } + } + + const allGenes = [...SAMPLE_GENES.quantity, ...SAMPLE_GENES.quality] + const filteredGenes = allGenes.filter(g => + g.name.toLowerCase().includes(geneSearch.toLowerCase()) || + g.description.toLowerCase().includes(geneSearch.toLowerCase()) + ) + + const allTraits = [...SAMPLE_TRAITS.economic, ...SAMPLE_TRAITS.growth, ...SAMPLE_TRAITS.body] + const filteredTraits = allTraits.filter(t => + t.name.toLowerCase().includes(traitSearch.toLowerCase()) || + t.description.toLowerCase().includes(traitSearch.toLowerCase()) + ) + + const sortedSelectedGenes = [ + ...pinnedGenes.filter(g => selectedGenes.includes(g)), + ...selectedGenes.filter(g => !pinnedGenes.includes(g)) + ] + const sortedSelectedTraits = [ + ...pinnedTraits.filter(t => selectedTraits.includes(t)), + ...selectedTraits.filter(t => !pinnedTraits.includes(t)) + ] + + return ( + + + + + 시안 E: 탭 분리 방식 + +

+ 유전자 / 형질 / 선택조건을 탭으로 분리하여 넓은 공간 활용 +

+
+ + + + + + 유전자 + {selectedGenes.length > 0 && {selectedGenes.length}} + + + + 형질 + {selectedTraits.length > 0 && {selectedTraits.length}} + + + 선택조건 + + + + {/* 유전자 탭 */} + + {/* 선택된 유전자 */} + {sortedSelectedGenes.length > 0 && ( +
+
선택됨 (드래그로 순서 변경)
+ {sortedSelectedGenes.map(geneId => ( + toggleGenePin(geneId)} + onRemove={() => toggleGene(geneId)} + /> + ))} +
+ )} + +
+ + setGeneSearch(e.target.value)} + /> +
+ +
+
육량형
+ {filteredGenes.filter(g => SAMPLE_GENES.quantity.some(q => q.id === g.id)).map(gene => ( +
toggleGene(gene.id)} + > +
+ + {gene.name} + {gene.description} +
+ {selectedGenes.includes(gene.id) && ( + + )} +
+ ))} +
육질형
+ {filteredGenes.filter(g => SAMPLE_GENES.quality.some(q => q.id === g.id)).map(gene => ( +
toggleGene(gene.id)} + > +
+ + {gene.name} + {gene.description} +
+ {selectedGenes.includes(gene.id) && ( + + )} +
+ ))} +
+
+ + {/* 형질 탭 */} + + {/* 선택된 형질 */} + {sortedSelectedTraits.length > 0 && ( +
+
선택됨 (드래그로 순서 변경)
+ {sortedSelectedTraits.map(traitId => ( + toggleTraitPin(traitId)} + onRemove={() => toggleTrait(traitId)} + showWeight={true} + weight={traitWeights[traitId]} + onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))} + /> + ))} +
+ )} + +
+ + setTraitSearch(e.target.value)} + /> +
+ +
+
경제형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.economic.some(e => e.id === t.id)).map(trait => ( +
toggleTrait(trait.id)} + > +
+ + {trait.name} + {trait.description} +
+ {selectedTraits.includes(trait.id) && ( + + )} +
+ ))} +
성장형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.growth.some(g => g.id === t.id)).map(trait => ( +
toggleTrait(trait.id)} + > +
+ + {trait.name} + {trait.description} +
+ {selectedTraits.includes(trait.id) && ( + + )} +
+ ))} +
체형형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.body.some(b => b.id === t.id)).map(trait => ( +
toggleTrait(trait.id)} + > +
+ + {trait.name} + {trait.description} +
+ {selectedTraits.includes(trait.id) && ( + + )} +
+ ))} +
+
+ + {/* 선택조건 탭 */} + +
+

선택한 필터 조건

+ + {/* 유전자 */} + {sortedSelectedGenes.length > 0 && ( +
+
+ + 유전자 ({sortedSelectedGenes.length}개) +
+
+ {sortedSelectedGenes.map(geneId => ( + toggleGenePin(geneId)} + onRemove={() => toggleGene(geneId)} + /> + ))} +
+
+ )} + + {/* 형질 */} + {sortedSelectedTraits.length > 0 && ( +
+
+ + 형질 ({sortedSelectedTraits.length}개) +
+
+ {sortedSelectedTraits.map(traitId => ( + toggleTraitPin(traitId)} + onRemove={() => toggleTrait(traitId)} + showWeight={true} + weight={traitWeights[traitId]} + onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))} + /> + ))} +
+
+ )} + + {sortedSelectedGenes.length === 0 && sortedSelectedTraits.length === 0 && ( +
+ 선택된 필터 조건이 없습니다 +
+ )} +
+
+
+ +
+ + +
+
+
+ ) +} + +/** + * 시안 H: 칩(Chip) 기반 방식 + * 태그/칩 형태로 선택 항목을 표시하고 관리 + */ +function OptionH() { + const [selectedGenes, setSelectedGenes] = useState(['PLAG1', 'NT5E', 'SCD']) + const [pinnedGenes, setPinnedGenes] = useState(['PLAG1', 'NT5E']) + const [selectedTraits, setSelectedTraits] = useState(['도체중', '등심단면적']) + const [pinnedTraits, setPinnedTraits] = useState(['도체중']) + const [traitWeights, setTraitWeights] = useState>({ '도체중': 7, '등심단면적': 5 }) + const [showGeneSelector, setShowGeneSelector] = useState(false) + const [showTraitSelector, setShowTraitSelector] = useState(false) + const [geneSearch, setGeneSearch] = useState('') + const [traitSearch, setTraitSearch] = useState('') + + const toggleGene = (geneId: string) => { + if (selectedGenes.includes(geneId)) { + setSelectedGenes(prev => prev.filter(g => g !== geneId)) + setPinnedGenes(prev => prev.filter(g => g !== geneId)) + } else { + setSelectedGenes(prev => [...prev, geneId]) + } + } + + const toggleGenePin = (geneId: string) => { + if (pinnedGenes.includes(geneId)) { + setPinnedGenes(prev => prev.filter(g => g !== geneId)) + } else if (pinnedGenes.length < 5) { + setPinnedGenes(prev => [...prev, geneId]) + } + } + + const toggleTrait = (traitId: string) => { + if (selectedTraits.includes(traitId)) { + setSelectedTraits(prev => prev.filter(t => t !== traitId)) + setPinnedTraits(prev => prev.filter(t => t !== traitId)) + setTraitWeights(prev => { + const newWeights = { ...prev } + delete newWeights[traitId] + return newWeights + }) + } else { + setSelectedTraits(prev => [...prev, traitId]) + setTraitWeights(prev => ({ ...prev, [traitId]: 5 })) + } + } + + const toggleTraitPin = (traitId: string) => { + if (pinnedTraits.includes(traitId)) { + setPinnedTraits(prev => prev.filter(t => t !== traitId)) + } else if (pinnedTraits.length < 5) { + setPinnedTraits(prev => [...prev, traitId]) + } + } + + const allGenes = [...SAMPLE_GENES.quantity, ...SAMPLE_GENES.quality] + const filteredGenes = allGenes.filter(g => + g.name.toLowerCase().includes(geneSearch.toLowerCase()) || + g.description.toLowerCase().includes(geneSearch.toLowerCase()) + ) + + const allTraits = [...SAMPLE_TRAITS.economic, ...SAMPLE_TRAITS.growth, ...SAMPLE_TRAITS.body] + const filteredTraits = allTraits.filter(t => + t.name.toLowerCase().includes(traitSearch.toLowerCase()) || + t.description.toLowerCase().includes(traitSearch.toLowerCase()) + ) + + // 고정된 것 먼저 정렬 + const sortedSelectedGenes = [ + ...pinnedGenes.filter(g => selectedGenes.includes(g)), + ...selectedGenes.filter(g => !pinnedGenes.includes(g)) + ] + const sortedSelectedTraits = [ + ...pinnedTraits.filter(t => selectedTraits.includes(t)), + ...selectedTraits.filter(t => !pinnedTraits.includes(t)) + ] + + return ( + + + + + 시안 H: 칩(Chip) 기반 방식 + +

+ 태그/칩 형태로 선택 항목 표시 + 추가 버튼으로 새 항목 선택 +

+
+ + {/* 유전자 칩 영역 */} +
+
+

+ + 유전자 +

+ +
+ + {/* 선택된 유전자 칩 */} +
+ {sortedSelectedGenes.length === 0 ? ( + 유전자를 선택해주세요 + ) : ( + sortedSelectedGenes.map(geneId => { + const isPinned = pinnedGenes.includes(geneId) + const isQuantity = SAMPLE_GENES.quantity.some(g => g.id === geneId) + return ( +
+ + {isPinned && } + {geneId} + + +
+ ) + }) + )} +
+ + {/* 유전자 선택 패널 */} + {showGeneSelector && ( +
+
+ + setGeneSearch(e.target.value)} + /> +
+
+
육량형
+ {filteredGenes.filter(g => SAMPLE_GENES.quantity.some(q => q.id === g.id)).map(gene => ( +
toggleGene(gene.id)} + > + + {gene.name} + {gene.description} +
+ ))} +
육질형
+ {filteredGenes.filter(g => SAMPLE_GENES.quality.some(q => q.id === g.id)).map(gene => ( +
toggleGene(gene.id)} + > + + {gene.name} + {gene.description} +
+ ))} +
+ +
+ )} +
+ + {/* 형질 칩 영역 */} +
+
+

+ + 형질 +

+ +
+ + {/* 선택된 형질 칩 */} +
+ {sortedSelectedTraits.length === 0 ? ( + 형질을 선택해주세요 + ) : ( + sortedSelectedTraits.map(traitId => { + const isPinned = pinnedTraits.includes(traitId) + return ( +
+ + {isPinned && } + {traitId} + ({traitWeights[traitId] || 0}점) + + +
+ ) + }) + )} +
+ + {/* 형질 가중치 조절 */} + {sortedSelectedTraits.length > 0 && ( +
+
가중치 조절
+ {sortedSelectedTraits.map(traitId => ( +
+ {traitId} + setTraitWeights(prev => ({ ...prev, [traitId]: v }))} + max={10} + step={1} + className="flex-1" + /> + {traitWeights[traitId] || 0}점 +
+ ))} +
+ )} + + {/* 형질 선택 패널 */} + {showTraitSelector && ( +
+
+ + setTraitSearch(e.target.value)} + /> +
+
+
경제형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.economic.some(e => e.id === t.id)).map(trait => ( +
toggleTrait(trait.id)} + > + + {trait.name} + {trait.description} +
+ ))} +
성장형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.growth.some(g => g.id === t.id)).map(trait => ( +
toggleTrait(trait.id)} + > + + {trait.name} + {trait.description} +
+ ))} +
체형형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.body.some(b => b.id === t.id)).map(trait => ( +
toggleTrait(trait.id)} + > + + {trait.name} + {trait.description} +
+ ))} +
+ +
+ )} +
+ +
+ + +
+
+
+ ) +} + +/** + * 시안 F: 모달 선택 방식 (추천) + * 평소엔 선택 결과만 보이고, 클릭하면 모달/패널에서 선택 + */ +function OptionF() { + const [selectedGenes, setSelectedGenes] = useState(['PLAG1', 'NT5E', 'SCD']) + const [pinnedGenes, setPinnedGenes] = useState(['PLAG1', 'NT5E']) + const [selectedTraits, setSelectedTraits] = useState(['도체중', '등심단면적']) + const [pinnedTraits, setPinnedTraits] = useState(['도체중']) + const [traitWeights, setTraitWeights] = useState>({ '도체중': 7, '등심단면적': 5 }) + + const [showGeneModal, setShowGeneModal] = useState(false) + const [showTraitModal, setShowTraitModal] = useState(false) + const [geneSearch, setGeneSearch] = useState('') + const [traitSearch, setTraitSearch] = useState('') + const [activeGeneCategory, setActiveGeneCategory] = useState(null) + const [activeTraitCategory, setActiveTraitCategory] = useState(null) + const [openCategories, setOpenCategories] = useState>({}) + + const toggleGene = (geneId: string) => { + if (selectedGenes.includes(geneId)) { + setSelectedGenes(prev => prev.filter(g => g !== geneId)) + setPinnedGenes(prev => prev.filter(g => g !== geneId)) + } else { + setSelectedGenes(prev => [...prev, geneId]) + } + } + + const toggleGenePin = (geneId: string) => { + if (pinnedGenes.includes(geneId)) { + setPinnedGenes(prev => prev.filter(g => g !== geneId)) + } else if (pinnedGenes.length < 5) { + setPinnedGenes(prev => [...prev, geneId]) + } + } + + const toggleTrait = (traitId: string) => { + if (selectedTraits.includes(traitId)) { + setSelectedTraits(prev => prev.filter(t => t !== traitId)) + setPinnedTraits(prev => prev.filter(t => t !== traitId)) + setTraitWeights(prev => { + const newWeights = { ...prev } + delete newWeights[traitId] + return newWeights + }) + } else { + setSelectedTraits(prev => [...prev, traitId]) + setTraitWeights(prev => ({ ...prev, [traitId]: 5 })) + } + } + + const toggleTraitPin = (traitId: string) => { + if (pinnedTraits.includes(traitId)) { + setPinnedTraits(prev => prev.filter(t => t !== traitId)) + } else if (pinnedTraits.length < 5) { + setPinnedTraits(prev => [...prev, traitId]) + } + } + + const allGenes = [...SAMPLE_GENES.quantity, ...SAMPLE_GENES.quality] + const allTraits = [...SAMPLE_TRAITS.growth, ...SAMPLE_TRAITS.economic, ...SAMPLE_TRAITS.body, ...SAMPLE_TRAITS.weight, ...SAMPLE_TRAITS.rate] + + // 형질 카테고리 정보 + const traitCategories = [ + { id: 'growth', name: '성장형질', color: 'purple', traits: SAMPLE_TRAITS.growth }, + { id: 'economic', name: '경제형질', color: 'emerald', traits: SAMPLE_TRAITS.economic }, + { id: 'body', name: '체형형질', color: 'cyan', traits: SAMPLE_TRAITS.body }, + { id: 'weight', name: '부위별무게', color: 'amber', traits: SAMPLE_TRAITS.weight }, + { id: 'rate', name: '부위별비율', color: 'rose', traits: SAMPLE_TRAITS.rate }, + ] + + // 카테고리 필터링 + const getFilteredGenes = () => { + let genes = allGenes + if (activeGeneCategory === 'quantity') { + genes = SAMPLE_GENES.quantity + } else if (activeGeneCategory === 'quality') { + genes = SAMPLE_GENES.quality + } + if (geneSearch) { + genes = genes.filter(g => + g.name.toLowerCase().includes(geneSearch.toLowerCase()) || + g.description.toLowerCase().includes(geneSearch.toLowerCase()) + ) + } + return genes + } + + const getFilteredTraits = () => { + const category = traitCategories.find(c => c.id === activeTraitCategory) + let traits = category ? category.traits : allTraits + if (traitSearch) { + traits = traits.filter(t => + t.name.toLowerCase().includes(traitSearch.toLowerCase()) || + t.description.toLowerCase().includes(traitSearch.toLowerCase()) + ) + } + return traits + } + + // 형질이 어떤 카테고리에 속하는지 찾기 + const getTraitCategory = (traitId: string) => { + for (const cat of traitCategories) { + if (cat.traits.some(t => t.id === traitId)) { + return cat + } + } + return null + } + + // 정렬: 고정된 것 먼저 + const sortedSelectedGenes = [ + ...pinnedGenes.filter(g => selectedGenes.includes(g)), + ...selectedGenes.filter(g => !pinnedGenes.includes(g)) + ] + const sortedSelectedTraits = [ + ...pinnedTraits.filter(t => selectedTraits.includes(t)), + ...selectedTraits.filter(t => !pinnedTraits.includes(t)) + ] + + return ( + + + + + 시안 F: 모달 선택 방식 (추천) + +

+ 평소엔 선택 결과만 보이고, 클릭하면 모달에서 카테고리/검색으로 선택 +

+
+ + {/* 유전자 선택 버튼 */} +
+
+

+ + 유전자 +

+
+ +
+ + {/* 유전체 형질 선택 버튼 */} +
+
+

+ + 유전체 형질 +

+
+ +
+ + {/* 선택한 조건 표시 */} +
+

+ 선택한 조건 ({selectedGenes.length + selectedTraits.length}개) +

+ + {/* 유전자 */} + {sortedSelectedGenes.length > 0 && ( +
+
유전자
+
+ {sortedSelectedGenes.map(geneId => { + const isPinned = pinnedGenes.includes(geneId) + return ( +
+ {isPinned && } + {geneId} + + +
+ ) + })} +
+
+ )} + + {/* 유전체 형질 */} + {sortedSelectedTraits.length > 0 && ( +
+
유전체 형질
+
+ {sortedSelectedTraits.map(traitId => { + const isPinned = pinnedTraits.includes(traitId) + return ( +
+ {isPinned && } + {traitId} + ({traitWeights[traitId]}점) + + +
+ ) + })} +
+ + {/* 가중치 조절 */} +
+
가중치 조절
+ {sortedSelectedTraits.map(traitId => ( +
+ {traitId} +
+ + {traitWeights[traitId] || 0}점 + +
+
+ ))} +
+
+ )} + + {selectedGenes.length === 0 && selectedTraits.length === 0 && ( +
+ 선택된 조건이 없습니다 +
+ )} +
+ +
+ + +
+ + {/* 유전자 선택 모달 */} + {showGeneModal && ( +
+
+
+

유전자 선택

+ +
+ +
+ {/* 검색창 */} +
+ + setGeneSearch(e.target.value)} + /> +
+ + {/* 유전자 목록 - 카테고리별 아코디언 */} +
+ {/* 육량형 */} + {(() => { + const genes = SAMPLE_GENES.quantity + const selectedCount = genes.filter(g => selectedGenes.includes(g.id)).length + const isAllSelected = selectedCount === genes.length + const hasSelected = selectedCount > 0 + + // 검색어가 카테고리 이름과 매칭되는지 확인 + const isCategoryMatch = geneSearch && '육량형'.includes(geneSearch.toLowerCase()) + + // 검색 필터링 + const filteredGenes = geneSearch + ? isCategoryMatch + ? genes + : genes.filter(g => + g.name.toLowerCase().includes(geneSearch.toLowerCase()) || + g.description.toLowerCase().includes(geneSearch.toLowerCase()) + ) + : genes + + if (geneSearch && filteredGenes.length === 0) return null + + const isExpanded = geneSearch ? true : (openCategories['quantity'] ?? false) + + return ( + setOpenCategories(prev => ({ ...prev, quantity: open }))} + > +
+
+
+ { + if (checked) { + const newGenes = [...selectedGenes] + genes.forEach(g => { + if (!newGenes.includes(g.id)) newGenes.push(g.id) + }) + setSelectedGenes(newGenes) + } else { + setSelectedGenes(prev => prev.filter(id => !genes.some(g => g.id === id))) + setPinnedGenes(prev => prev.filter(id => !genes.some(g => g.id === id))) + } + }} + className="h-4 w-4" + /> + + 육량형 + + + {selectedCount}/{genes.length} + +
+ + + +
+ +
+ {filteredGenes.map(gene => { + const isSelected = selectedGenes.includes(gene.id) + return ( +
toggleGene(gene.id)} + > +
+ +
+ {gene.name} + {gene.description} +
+
+
+ ) + })} +
+
+
+
+ ) + })()} + + {/* 육질형 */} + {(() => { + const genes = SAMPLE_GENES.quality + const selectedCount = genes.filter(g => selectedGenes.includes(g.id)).length + const isAllSelected = selectedCount === genes.length + const hasSelected = selectedCount > 0 + + const isCategoryMatch = geneSearch && '육질형'.includes(geneSearch.toLowerCase()) + + const filteredGenes = geneSearch + ? isCategoryMatch + ? genes + : genes.filter(g => + g.name.toLowerCase().includes(geneSearch.toLowerCase()) || + g.description.toLowerCase().includes(geneSearch.toLowerCase()) + ) + : genes + + if (geneSearch && filteredGenes.length === 0) return null + + const isExpanded = geneSearch ? true : (openCategories['quality'] ?? false) + + return ( + setOpenCategories(prev => ({ ...prev, quality: open }))} + > +
+
+
+ { + if (checked) { + const newGenes = [...selectedGenes] + genes.forEach(g => { + if (!newGenes.includes(g.id)) newGenes.push(g.id) + }) + setSelectedGenes(newGenes) + } else { + setSelectedGenes(prev => prev.filter(id => !genes.some(g => g.id === id))) + setPinnedGenes(prev => prev.filter(id => !genes.some(g => g.id === id))) + } + }} + className="h-4 w-4" + /> + + 육질형 + + + {selectedCount}/{genes.length} + +
+ + + +
+ +
+ {filteredGenes.map(gene => { + const isSelected = selectedGenes.includes(gene.id) + return ( +
toggleGene(gene.id)} + > +
+ +
+ {gene.name} + {gene.description} +
+
+
+ ) + })} +
+
+
+
+ ) + })()} +
+
+ +
+ + +
+
+
+ )} + + {/* 유전체 형질 선택 모달 */} + {showTraitModal && ( +
+
+
+

유전체 형질 선택

+ +
+ +
+ {/* 검색창 */} +
+ + setTraitSearch(e.target.value)} + /> +
+ + {/* 형질 목록 - 카테고리별 아코디언 */} +
+ {traitCategories.map(cat => { + const selectedCount = cat.traits.filter(t => selectedTraits.includes(t.id)).length + const isAllSelected = selectedCount === cat.traits.length && cat.traits.length > 0 + const hasSelected = selectedCount > 0 + + // 검색어가 카테고리 이름과 매칭되는지 확인 + const isCategoryMatch = traitSearch && cat.name.toLowerCase().includes(traitSearch.toLowerCase()) + + // 검색 필터링: 카테고리 이름 매칭이면 전체 표시, 아니면 개별 형질 필터링 + const filteredTraits = traitSearch + ? isCategoryMatch + ? cat.traits + : cat.traits.filter(t => + t.name.toLowerCase().includes(traitSearch.toLowerCase()) || + t.description.toLowerCase().includes(traitSearch.toLowerCase()) + ) + : cat.traits + + // 검색 결과가 없으면 카테고리 숨기기 + if (traitSearch && filteredTraits.length === 0) return null + + const colorClasses: Record = { + purple: { bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-200' }, + emerald: { bg: 'bg-emerald-50', text: 'text-emerald-700', border: 'border-emerald-200' }, + cyan: { bg: 'bg-cyan-50', text: 'text-cyan-700', border: 'border-cyan-200' }, + amber: { bg: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-200' }, + rose: { bg: 'bg-rose-50', text: 'text-rose-700', border: 'border-rose-200' }, + } + const colors = colorClasses[cat.color] + + // 검색 중이거나 선택된 항목이 있으면 자동으로 펼침 + const isExpanded = traitSearch ? true : (openCategories[cat.id] ?? false) + + return ( + setOpenCategories(prev => ({ ...prev, [cat.id]: open }))} + > +
+ {/* 카테고리 헤더 */} +
+
+ { + if (checked) { + const newTraits = [...selectedTraits] + const newWeights = { ...traitWeights } + cat.traits.forEach(t => { + if (!newTraits.includes(t.id)) { + newTraits.push(t.id) + newWeights[t.id] = 5 + } + }) + setSelectedTraits(newTraits) + setTraitWeights(newWeights) + } else { + setSelectedTraits(prev => prev.filter(id => !cat.traits.some(t => t.id === id))) + setTraitWeights(prev => { + const newWeights = { ...prev } + cat.traits.forEach(t => delete newWeights[t.id]) + return newWeights + }) + } + }} + className="h-4 w-4" + /> + + {cat.name} + + + {selectedCount}/{cat.traits.length} + +
+ + + +
+ + {/* 형질 목록 */} + +
+ {filteredTraits.map(trait => { + const isSelected = selectedTraits.includes(trait.id) + return ( +
+
toggleTrait(trait.id)} + > + +
+ {trait.name} + {trait.description} +
+
+ {isSelected && ( +
+ 가중치 +
+ + {traitWeights[trait.id] || 0}점 + +
+
+ )} +
+ ) + })} +
+
+
+
+ ) + })} +
+
+ +
+ + +
+
+
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/app/demo/sidebar-colors/page.tsx b/frontend/src/app/demo/sidebar-colors/page.tsx new file mode 100644 index 0000000..f28abb8 --- /dev/null +++ b/frontend/src/app/demo/sidebar-colors/page.tsx @@ -0,0 +1,256 @@ +'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 ( +
+

사이드바 색상 조합 데모

+

로그인 페이지(흰색 배경)와 어울리는 사이드바 스타일 비교

+ +
+ {colorSchemes.map((scheme, index) => ( +
+ {/* 미니 레이아웃 프리뷰 */} +
+ {/* 사이드바 */} +
+ {/* 헤더 */} +
+
+
+ H +
+ 한우 유전체 +
+
+ + {/* 메뉴 */} +
+ {/* 활성 메뉴 */} +
+ + 대시보드 +
+ + {/* 비활성 메뉴 */} +
+ + 개체 조회 +
+
+
+ + {/* 메인 콘텐츠 영역 */} +
+
+
+ 메인 콘텐츠 +
+
+
+
+ + {/* 정보 */} +
+

{scheme.name}

+

{scheme.description}

+
+
+ ))} +
+ + {/* 헤더 연결 스타일 비교 */} +

헤더 연결 스타일 비교

+
+ {/* 현재: 흰색 헤더 + 파란 사이드바 */} +
+
+
+ {/* 흰색 헤더 */} +
+
+
+ H +
+ 한우 유전체 분석 +
+
+ + {/* 파란 콘텐츠 */} +
+
+ + 대시보드 +
+
+ + 개체 조회 +
+
+
+
+
+
+

현재 스타일

+

흰색 헤더 → 파란 사이드바, 활성 메뉴 흰색

+
+
+ + {/* 대안: 전체 밝은 톤 */} +
+
+
+ {/* 헤더 */} +
+
+
+ H +
+ 한우 유전체 분석 +
+
+ + {/* 콘텐츠 */} +
+
+ + 대시보드 +
+
+ + 개체 조회 +
+
+
+
+
+
+

밝은 톤 스타일

+

전체 밝은 톤, 로그인 페이지와 자연스러운 연결

+
+
+ + {/* 대안: 파란 헤더 + 파란 사이드바 */} +
+
+
+ {/* 파란 헤더 */} +
+
+
+ H +
+ 한우 유전체 분석 +
+
+ + {/* 콘텐츠 */} +
+
+ + 대시보드 +
+
+ + 개체 조회 +
+
+
+
+
+
+

전체 파란색 스타일

+

통일된 파란색, 강한 브랜드 아이덴티티

+
+
+ + {/* 대안: 왼쪽 강조선 */} +
+
+
+ {/* 헤더 */} +
+
+
+ H +
+ 한우 유전체 분석 +
+
+ + {/* 콘텐츠 */} +
+
+ + 대시보드 +
+
+ + 개체 조회 +
+
+
+
+
+
+

왼쪽 강조선 스타일

+

미니멀하고 깔끔한 네비게이션

+
+
+
+
+ ); +} diff --git a/frontend/src/components/common/global-filter-dialog.tsx b/frontend/src/components/common/global-filter-dialog.tsx index f75f537..5e0b9a7 100644 --- a/frontend/src/components/common/global-filter-dialog.tsx +++ b/frontend/src/components/common/global-filter-dialog.tsx @@ -2,26 +2,66 @@ import { useState, useEffect } from "react" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet" -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" -import { Label } from "@/components/ui/label" -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" -import { Slider } from "@/components/ui/slider" 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Settings2, Filter, X, Search, ChevronDown, ChevronUp, Loader2, HelpCircle, Dna, Activity, GripVertical } from "lucide-react" +import { Settings2, Filter, X, Search, ChevronDown, ChevronUp, Loader2, Plus, Pin } from "lucide-react" import { useGlobalFilter } from "@/contexts/GlobalFilterContext" -import { DEFAULT_FILTER_SETTINGS, MAJOR_GENES } from "@/types/filter.types" +import { DEFAULT_FILTER_SETTINGS } from "@/types/filter.types" import { geneApi, type MarkerModel } from "@/lib/api/gene.api" -import { GeneSearchModal } from "@/components/genome/gene-search-modal" -import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, DragStartEvent, DragOverlay, useDroppable, DragOverEvent } from '@dnd-kit/core' -import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' + +// 형질 카테고리 정의 +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 = { + '12개월령체중': '12개월 시점 체중', + '도체중': '도축 후 고기 무게', + '등심단면적': '등심 부위 크기', + '등지방두께': '등 부위 지방 두께', + '근내지방도': '마블링 정도', + '체고': '어깨 높이', + '십자': '엉덩이뼈 높이', + '체장': '몸통 길이', + '흉심': '가슴 깊이', + '흉폭': '가슴 너비', + '고장': '허리뼈 길이', + '요각폭': '허리뼈 너비', + '좌골폭': '엉덩이뼈 너비', + '곤폭': '엉덩이뼈 끝 너비', + '흉위': '가슴 둘레', + '안심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 { externalOpen?: boolean @@ -32,57 +72,21 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa const { filters, updateFilters, resetFilters } = useGlobalFilter() const [internalOpen, setInternalOpen] = useState(false) - // 외부에서 제어하는 경우 외부 상태 사용, 아니면 내부 상태 사용 const open = externalOpen !== undefined ? externalOpen : internalOpen const setOpen = onExternalOpenChange || setInternalOpen - const [geneSearchModalOpen, setGeneSearchModalOpen] = useState(false) // 유전자 검색 모달 - const [weightsOpen, setWeightsOpen] = useState(true) // 기본으로 펼쳐놓기 - const [filterConditionOpen, setFilterConditionOpen] = useState(true) // 선택한 필터 조건 드롭다운 - // 고정 항목 검색 모달 상태 - const [pinnedSearchModalOpen, setPinnedSearchModalOpen] = useState(false) - const [pinnedSearchType, setPinnedSearchType] = useState<'gene' | 'trait'>('gene') - const [pinnedSearchQuery, setPinnedSearchQuery] = useState("") - - // 검색 상태 - const [searchQuery, setSearchQuery] = useState("") - const [searchType, setSearchType] = useState<'all' | 'genes' | 'traits'>('all') - - // 유전자 정렬 옵션 - const [geneSortOption, setGeneSortOption] = useState<'asc' | 'desc'>('asc') - - - // 드래그 앤 드롭 상태 - const [activeId, setActiveId] = useState(null) - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ) - - - // 카테고리 정의 - const categories = [ - { id: 'quantity', name: '육량형', type: 'gene' as const }, - { id: 'quality', name: '육질형', type: 'gene' as const }, - { id: 'traits', name: '경제형질', type: 'trait' as const }, - ] + // 모달 상태 + const [showGeneModal, setShowGeneModal] = useState(false) + const [showTraitModal, setShowTraitModal] = useState(false) + const [geneSearch, setGeneSearch] = useState('') + const [traitSearch, setTraitSearch] = useState('') // 카테고리 열림/닫힘 상태 - const [categoryOpen, setCategoryOpen] = useState({ - quantity: false, // 육량형 - quality: false, // 육질형 - growth: false, // 성장형질 - economic: false, // 경제형질 - body: false, // 체형형질 - weight: false, // 부위별무게 - rate: false, // 부위별비율 - }) + const [openCategories, setOpenCategories] = useState>({}) - // DB에서 가져온 유전자 목록 (전체) - const [quantityGenes, setQuantityGenes] = useState([]) // 육량형 (전체) - const [qualityGenes, setQualityGenes] = useState([]) // 육질형 (전체) + // DB에서 가져온 유전자 목록 + const [quantityGenes, setQuantityGenes] = useState([]) + const [qualityGenes, setQualityGenes] = useState([]) const [loadingGenes, setLoadingGenes] = useState(false) // 로컬 상태 @@ -91,9 +95,6 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa ...filters, }) - // 변경사항이 있는지 확인 - const hasChanges = JSON.stringify(localFilters) !== JSON.stringify(filters) - useEffect(() => { if (open) { setLocalFilters({ @@ -101,34 +102,20 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa ...filters, }) loadAllGenes() - // 검색창 초기화 - setSearchQuery("") - setSearchType('all') - setGeneSortOption('asc') - // 카테고리 모두 닫기 - setCategoryOpen({ - quantity: false, - quality: false, - growth: false, - economic: false, - body: false, - weight: false, - rate: false, - }) + setGeneSearch('') + setTraitSearch('') + setOpenCategories({}) } }, [open, filters]) - // 전체 유전자 로드 (필터링 없이 모두 가져오기) + // 전체 유전자 로드 const loadAllGenes = async () => { try { setLoadingGenes(true) - - // 육량형과 육질형 각각 불러오기 (전체) const [qtyGenes, qltGenes] = await Promise.all([ - geneApi.getGenesByType('QTY'), // 육량형 - geneApi.getGenesByType('QLT'), // 육질형 + geneApi.getGenesByType('QTY'), + geneApi.getGenesByType('QLT'), ]) - setQuantityGenes(qtyGenes) setQualityGenes(qltGenes) } catch { @@ -139,21 +126,15 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa } } - // 필터 활성화 여부 const isFilterActive = (filterSettings: typeof localFilters) => { - // 유전자가 선택되었거나 const hasSelectedGenes = filterSettings.selectedGenes.length > 0 - - // 가중치가 기본값(0)과 다를 때 활성 - 모든 형질 체크 const hasCustomWeights = Object.values(filterSettings.traitWeights).some(weight => weight > 0) - return hasSelectedGenes || hasCustomWeights } // 필터 적용 const handleApply = () => { - // 점수 방식은 합계 검증 불필요 (0-10 범위만 체크) const updatedFilters = { ...localFilters, isActive: isFilterActive(localFilters) @@ -174,556 +155,133 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa ...prev, selectedGenes: prev.selectedGenes.includes(markerNm) ? prev.selectedGenes.filter(g => g !== markerNm) - : [...prev.selectedGenes, markerNm] + : [...prev.selectedGenes, markerNm], + pinnedGenes: prev.selectedGenes.includes(markerNm) + ? prev.pinnedGenes?.filter(g => g !== markerNm) || [] + : prev.pinnedGenes })) } - // 드래그 시작 - const handleDragStart = (event: DragStartEvent) => { - setActiveId(event.active.id as string) - } - - // 드래그 종료 (유전자) - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event - - if (!over) { - setActiveId(null) - return - } - - const activeGene = active.id as string - const pinnedGenes = localFilters.pinnedGenes || [] - const isActivePinned = pinnedGenes.includes(activeGene) - - // 고정 영역으로 드롭한 경우 (나머지 → 고정) - // over.id가 드롭존 ID이거나 고정된 유전자 중 하나일 때 - if ((over.id === 'pinned-genes-drop-zone' || pinnedGenes.includes(over.id as string)) && !isActivePinned) { - setLocalFilters(prev => { - const currentPinned = prev.pinnedGenes || [] - // 이미 고정되어 있으면 추가 안함 - if (currentPinned.includes(activeGene)) { - return prev - } - // 드롭존에 놓으면 맨 뒤에 추가, 특정 유전자 위에 놓으면 그 위치에 삽입 - if (over.id === 'pinned-genes-drop-zone') { - return { - ...prev, - pinnedGenes: [...currentPinned, activeGene] - } - } else { - const targetIndex = currentPinned.indexOf(over.id as string) - const newPinned = [...currentPinned] - newPinned.splice(targetIndex, 0, activeGene) - return { - ...prev, - pinnedGenes: newPinned - } - } - }) - setActiveId(null) - return - } - - // 나머지 영역으로 드롭한 경우 (고정 → 나머지 = 고정 해제) - if ((over.id === 'remaining-genes-area' || (!pinnedGenes.includes(over.id as string) && localFilters.selectedGenes.includes(over.id as string))) && isActivePinned) { - setLocalFilters(prev => ({ - ...prev, - pinnedGenes: prev.pinnedGenes?.filter(g => g !== activeGene) || [] - })) - setActiveId(null) - return - } - - // 고정 영역 내부에서 순서 변경 - if (isActivePinned && pinnedGenes.includes(over.id as string)) { - setLocalFilters(prev => { - const pinnedGenes = prev.pinnedGenes || [] - const oldIndex = pinnedGenes.indexOf(activeGene) - const newIndex = pinnedGenes.indexOf(over.id as string) - - return { - ...prev, - pinnedGenes: arrayMove(pinnedGenes, oldIndex, newIndex) - } - }) - setActiveId(null) - return - } - - // 나머지 영역에서 순서 변경 - if (!isActivePinned && active.id !== over.id) { - setLocalFilters(prev => { - const oldIndex = prev.selectedGenes.indexOf(activeGene) - const newIndex = prev.selectedGenes.indexOf(over.id as string) - - return { - ...prev, - selectedGenes: arrayMove(prev.selectedGenes, oldIndex, newIndex) - } - }) - } - - setActiveId(null) - } - - // 드래그 종료 (형질) - const handleTraitDragEnd = (event: DragEndEvent) => { - const { active, over } = event - - if (!over) { - setActiveId(null) - return - } - - const activeTrait = active.id as string - const pinnedTraits = localFilters.pinnedTraits || [] - const isActivePinned = pinnedTraits.includes(activeTrait) - - // 고정 영역으로 드롭한 경우 (나머지 → 고정) - // over.id가 드롭존 ID이거나 고정된 형질 중 하나일 때 - if ((over.id === 'pinned-traits-drop-zone' || pinnedTraits.includes(over.id as string)) && !isActivePinned) { - setLocalFilters(prev => { - const currentPinned = prev.pinnedTraits || [] - // 이미 고정되어 있으면 추가 안함 - if (currentPinned.includes(activeTrait)) { - return prev - } - // 드롭존에 놓으면 맨 뒤에 추가, 특정 형질 위에 놓으면 그 위치에 삽입 - if (over.id === 'pinned-traits-drop-zone') { - return { - ...prev, - pinnedTraits: [...currentPinned, activeTrait] - } - } else { - const targetIndex = currentPinned.indexOf(over.id as string) - const newPinned = [...currentPinned] - newPinned.splice(targetIndex, 0, activeTrait) - return { - ...prev, - pinnedTraits: newPinned - } - } - }) - setActiveId(null) - return - } - - // 나머지 영역으로 드롭한 경우 (고정 → 나머지 = 고정 해제) - const selectedTraits = localFilters.selectedTraits || [] - if ((over.id === 'remaining-traits-area' || (!pinnedTraits.includes(over.id as string) && selectedTraits.includes(over.id as string))) && isActivePinned) { - setLocalFilters(prev => ({ - ...prev, - pinnedTraits: prev.pinnedTraits?.filter(t => t !== activeTrait) || [] - })) - setActiveId(null) - return - } - - // 고정 영역 내부에서 순서 변경 - if (isActivePinned && pinnedTraits.includes(over.id as string)) { - setLocalFilters(prev => { - const pinnedTraits = prev.pinnedTraits || [] - const oldIndex = pinnedTraits.indexOf(activeTrait) - const newIndex = pinnedTraits.indexOf(over.id as string) - - return { - ...prev, - pinnedTraits: arrayMove(pinnedTraits, oldIndex, newIndex) - } - }) - setActiveId(null) - return - } - - // 나머지 영역에서 순서 변경 - if (!isActivePinned && active.id !== over.id) { - setLocalFilters(prev => { - const selectedTraits = prev.selectedTraits || [] - const oldIndex = selectedTraits.indexOf(activeTrait) - const newIndex = selectedTraits.indexOf(over.id as string) - - return { - ...prev, - selectedTraits: arrayMove(selectedTraits, oldIndex, newIndex) - } - }) - } - - setActiveId(null) - } - - // 드래그 종료 (고정 유전자) - const handlePinnedGeneDragEnd = (event: DragEndEvent) => { - const { active, over } = event - - if (over && active.id !== over.id) { - setLocalFilters(prev => { - const pinnedGenes = prev.pinnedGenes || [] - const oldIndex = pinnedGenes.indexOf(active.id as string) - const newIndex = pinnedGenes.indexOf(over.id as string) - - return { - ...prev, - pinnedGenes: arrayMove(pinnedGenes, oldIndex, newIndex) - } - }) - } - - setActiveId(null) - } - - // 드래그 종료 (고정 형질) - const handlePinnedTraitDragEnd = (event: DragEndEvent) => { - const { active, over } = event - - if (over && active.id !== over.id) { - setLocalFilters(prev => { - const pinnedTraits = prev.pinnedTraits || [] - const oldIndex = pinnedTraits.indexOf(active.id as string) - const newIndex = pinnedTraits.indexOf(over.id as string) - - return { - ...prev, - pinnedTraits: arrayMove(pinnedTraits, oldIndex, newIndex) - } - }) - } - - setActiveId(null) - } - - // 카테고리에서 유전자를 드롭 영역으로 추가 - const handleAddGene = (geneName: string) => { - if (!localFilters.selectedGenes.includes(geneName)) { - setLocalFilters(prev => ({ - ...prev, - selectedGenes: [...prev.selectedGenes, geneName] - })) - } - } - - - // 가중치 변경 (점수: -10 ~ +10) - const updateTraitWeight = (traitName: TraitName, value: number) => { + // 유전자 고정 토글 + const toggleGenePin = (markerNm: string) => { setLocalFilters(prev => { - // 0 ~ 10 범위로 제한 - const adjustedValue = Math.min(10, Math.max(0, value)) - - // 새로운 가중치 객체 - const newWeights = { - ...prev.traitWeights, - [traitName]: adjustedValue - } - - // selectedTraits 배열 업데이트 (가중치 > 0인 형질들만 포함, 기존 순서 유지) - const currentSelected = prev.selectedTraits || [] - let newSelectedTraits: string[] - - if (adjustedValue > 0) { - // 가중치가 0보다 크면 추가 (이미 있으면 유지) - if (!currentSelected.includes(traitName)) { - newSelectedTraits = [...currentSelected, traitName] - } else { - newSelectedTraits = currentSelected - } + const pinnedGenes = prev.pinnedGenes || [] + if (pinnedGenes.includes(markerNm)) { + return { ...prev, pinnedGenes: pinnedGenes.filter(g => g !== markerNm) } } else { - // 가중치가 0이면 제거 - newSelectedTraits = currentSelected.filter(t => t !== traitName) - } - - return { - ...prev, - traitWeights: newWeights, - selectedTraits: newSelectedTraits + return { ...prev, pinnedGenes: [...pinnedGenes, markerNm] } } }) } - // 형질별 설명 - const traitDescriptions: Record = { - // 성장형질 - '12개월령체중': '12개월 시점의 체중. 높을수록 성장이 좋음', + // 형질 토글 + const toggleTrait = (traitName: string) => { + setLocalFilters(prev => { + const selectedTraits = prev.selectedTraits || [] + const traitWeights = { ...prev.traitWeights } - // 경제형질 - '도체중': '도축 후 뼈와 내장을 제외한 고기 무게. 높을수록 좋음', - '등심단면적': '등심 부위의 크기. 넓을수록 고급육 생산에 유리', - '등지방두께': '등 부위 지방 두께. 적당히 얇을수록 좋음', - '근내지방도': '고기 내 지방(마블링) 정도. 높을수록 육질 우수', - - // 체형형질 - DB 형질명과 일치 - '체고': '어깨 높이. 높을수록 좋음', - '십자': '엉덩이뼈 높이. 높을수록 좋음', - '체장': '몸통 길이. 길수록 좋음', - '흉심': '가슴 깊이. 깊을수록 좋음', - '흉폭': '가슴 너비. 넓을수록 좋음', - '고장': '허리뼈 길이. 길수록 좋음', - '요각폭': '허리뼈 너비. 넓을수록 좋음', - '좌골폭': '엉덩이뼈 너비. 넓을수록 좋음', - '곤폭': '엉덩이뼈 끝 너비. 넓을수록 좋음', - '흉위': '가슴 둘레. 클수록 좋음', - - // 부위별 무게 - DB 형질명과 일치 - '안심weight': '안심 부위 무게. 높을수록 좋음', - '등심weight': '등심 부위 무게. 높을수록 좋음', - '채끝weight': '채끝 부위 무게. 높을수록 좋음', - '목심weight': '목심 부위 무게. 높을수록 좋음', - '앞다리weight': '앞다리 부위 무게. 높을수록 좋음', - '우둔weight': '우둔 부위 무게. 높을수록 좋음', - '설도weight': '설도 부위 무게. 높을수록 좋음', - '사태weight': '사태 부위 무게. 높을수록 좋음', - '양지weight': '양지 부위 무게. 높을수록 좋음', - '갈비weight': '갈비 부위 무게. 높을수록 좋음', - - // 부위별 비율 - DB 형질명과 일치 - '안심rate': '전체 대비 안심 비율. 높을수록 좋음', - '등심rate': '전체 대비 등심 비율. 높을수록 좋음', - '채끝rate': '전체 대비 채끝 비율. 높을수록 좋음', - '목심rate': '전체 대비 목심 비율. 높을수록 좋음', - '앞다리rate': '전체 대비 앞다리 비율. 높을수록 좋음', - '우둔rate': '전체 대비 우둔 비율. 높을수록 좋음', - '설도rate': '전체 대비 설도 비율. 높을수록 좋음', - '사태rate': '전체 대비 사태 비율. 높을수록 좋음', - '양지rate': '전체 대비 양지 비율. 높을수록 좋음', - '갈비rate': '전체 대비 갈비 비율. 높을수록 좋음', + if (selectedTraits.includes(traitName)) { + // 제거 + traitWeights[traitName as TraitName] = 0 + return { + ...prev, + selectedTraits: selectedTraits.filter(t => t !== traitName), + pinnedTraits: prev.pinnedTraits?.filter(t => t !== traitName) || [], + traitWeights + } + } else { + // 추가 + traitWeights[traitName as TraitName] = 5 + return { + ...prev, + selectedTraits: [...selectedTraits, traitName], + traitWeights + } + } + }) } - // 형질별 라벨 (왼쪽: 낮게, 오른쪽: 높게) - const traitLabels: Record = { - // 성장형질 - '12개월령체중': { left: '낮게', right: '높게' }, - - // 경제형질 - '도체중': { left: '낮게', right: '높게' }, - '등심단면적': { left: '낮게', right: '높게' }, - '등지방두께': { left: '두껍게', right: '얇게' }, // 반대! - '근내지방도': { left: '낮게', right: '높게' }, - - // 체형형질 (클수록 좋음) - DB 형질명과 일치 - '체고': { left: '낮게', right: '높게' }, - '십자': { left: '낮게', right: '높게' }, - '체장': { left: '짧게', right: '길게' }, - '흉심': { left: '얕게', right: '깊게' }, - '흉폭': { left: '좁게', right: '넓게' }, - '고장': { left: '짧게', right: '길게' }, - '요각폭': { left: '좁게', right: '넓게' }, - '좌골폭': { left: '좁게', right: '넓게' }, - '곤폭': { left: '좁게', right: '넓게' }, - '흉위': { left: '작게', right: '크게' }, - - // 부위별 무게 (높을수록 좋음) - DB 형질명과 일치 - '안심weight': { left: '낮게', right: '높게' }, - '등심weight': { left: '낮게', right: '높게' }, - '채끝weight': { left: '낮게', right: '높게' }, - '목심weight': { left: '낮게', right: '높게' }, - '앞다리weight': { left: '낮게', right: '높게' }, - '우둔weight': { left: '낮게', right: '높게' }, - '설도weight': { left: '낮게', right: '높게' }, - '사태weight': { left: '낮게', right: '높게' }, - '양지weight': { left: '낮게', right: '높게' }, - '갈비weight': { left: '낮게', right: '높게' }, - - // 부위별 비율 (높을수록 좋음) - DB 형질명과 일치 - '안심rate': { left: '낮게', right: '높게' }, - '등심rate': { left: '낮게', right: '높게' }, - '채끝rate': { left: '낮게', right: '높게' }, - '목심rate': { left: '낮게', right: '높게' }, - '앞다리rate': { left: '낮게', right: '높게' }, - '우둔rate': { left: '낮게', right: '높게' }, - '설도rate': { left: '낮게', right: '높게' }, - '사태rate': { left: '낮게', right: '높게' }, - '양지rate': { left: '낮게', right: '높게' }, - '갈비rate': { left: '낮게', right: '높게' }, + // 형질 고정 토글 + const toggleTraitPin = (traitName: string) => { + setLocalFilters(prev => { + const pinnedTraits = prev.pinnedTraits || [] + if (pinnedTraits.includes(traitName)) { + return { ...prev, pinnedTraits: pinnedTraits.filter(t => t !== traitName) } + } else { + return { ...prev, pinnedTraits: [...pinnedTraits, traitName] } + } + }) } - // 형질 카테고리 및 형질 목록 - DB 형질명과 일치 - const traitCategories = { - growth: { - name: '성장형질', - traits: ['12개월령체중'] as const - }, - economic: { - name: '경제형질', - traits: ['도체중', '등심단면적', '등지방두께', '근내지방도'] as const - }, - body: { - name: '체형형질', - traits: ['체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위'] as const - }, - weight: { - name: '부위별무게', - traits: ['안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight', '우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight'] as const - }, - rate: { - name: '부위별비율', - traits: ['안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate', '우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate'] as const - } - } - - type TraitName = keyof typeof localFilters.traitWeights - - // 검색 필터링 로직 - 유전자 이름만 검색 (형질 검색과 동일하게) - const filterBySearch = (items: MarkerModel[]) => { - if (!searchQuery.trim()) return items - const query = searchQuery.toLowerCase() - return items.filter(item => - item.markerNm.toLowerCase().includes(query) - ) - } - - // 카테고리 이름 매칭 여부 확인 - const isQuantityCategoryMatch = () => { - if (!searchQuery.trim()) return false - const query = searchQuery.toLowerCase() - // "육량형" 또는 "육량" 또는 "quantity"가 검색어로 시작하면 매칭 - return '육량형'.startsWith(query) || '육량'.startsWith(query) || 'quantity'.startsWith(query) - } - - const isQualityCategoryMatch = () => { - if (!searchQuery.trim()) return false - const query = searchQuery.toLowerCase() - // "육질형" 또는 "육질" 또는 "quality"가 검색어로 시작하면 매칭 - return '육질형'.startsWith(query) || '육질'.startsWith(query) || 'quality'.startsWith(query) - } - - // 유전자 정렬 함수 - const sortGenes = (genes: MarkerModel[]) => { - const sorted = [...genes] - switch (geneSortOption) { - case 'asc': - // 오름차순 (A → Z) - return sorted.sort((a, b) => a.markerNm.localeCompare(b.markerNm)) - case 'desc': - // 내림차순 (Z → A) - return sorted.sort((a, b) => b.markerNm.localeCompare(a.markerNm)) - default: - return sorted - } - } - - // 유전자 필터링 - 카테고리 이름 매칭 시 전체 표시, 아니면 유전자 이름만 검색 - const filteredQuantityGenes = sortGenes(isQuantityCategoryMatch() ? quantityGenes : filterBySearch(quantityGenes)) - const filteredQualityGenes = sortGenes(isQualityCategoryMatch() ? qualityGenes : filterBySearch(qualityGenes)) - const totalFilteredGenes = [...filteredQuantityGenes, ...filteredQualityGenes] - - // 검색 시 카테고리 자동 펼치기/닫기 (유전자 + 형질) - useEffect(() => { - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase() - - // 유전자 카테고리 - 유전자가 매칭되거나 카테고리 이름이 매칭되면 자동으로 열기 - const shouldOpenQuantity = filteredQuantityGenes.length > 0 || '육량형'.startsWith(query) || '육량'.startsWith(query) || 'quantity'.startsWith(query) - const shouldOpenQuality = filteredQualityGenes.length > 0 || '육질형'.startsWith(query) || '육질'.startsWith(query) || 'quality'.startsWith(query) - - // 검색 결과에 따라 동적으로 열기/닫기 (이전 상태 무시) - setCategoryOpen(prev => ({ + // 가중치 변경 + const updateTraitWeight = (traitName: string, delta: number) => { + setLocalFilters(prev => { + const current = prev.traitWeights[traitName as TraitName] || 0 + const newValue = Math.min(10, Math.max(0, current + delta)) + return { ...prev, - quantity: shouldOpenQuantity, - quality: shouldOpenQuality - })) - - // 형질 카테고리 - 형질이 매칭되거나 카테고리 이름이 매칭되면 자동으로 열기 - const newTraitCategoryStates: Record = {} - Object.entries(traitCategories).forEach(([key, category]) => { - const hasTraitResults = (category.traits as unknown as string[]).some(trait => - trait.toLowerCase().includes(query) - ) - const hasCategoryNameMatch = category.name.toLowerCase().includes(query) - - const shouldOpen = hasTraitResults || hasCategoryNameMatch - newTraitCategoryStates[key] = shouldOpen // 검색 결과에 따라서만 열기/닫기 - }) - - setCategoryOpen(prev => ({ ...prev, ...newTraitCategoryStates })) - } else { - // 검색어가 없으면 모든 카테고리 닫기 - setCategoryOpen({ - quantity: false, - quality: false, - growth: false, - economic: false, - body: false, - weight: false, - rate: false - }) - } - }, [searchQuery, filteredQuantityGenes.length, filteredQualityGenes.length]) - - // 형질도 검색 필터링 - 모든 카테고리의 형질 포함 - const allTraits = Object.values(traitCategories).flatMap(cat => cat.traits as unknown as string[]) - const filteredTraits = searchQuery.trim() - ? allTraits.filter(trait => trait.toLowerCase().includes(searchQuery.toLowerCase())) - : allTraits - - // 카테고리 검색 필터링 - const filteredCategories = searchQuery.trim() - ? categories.filter(cat => cat.name.toLowerCase().includes(searchQuery.toLowerCase())) - : [] - - // 검색 중인지 여부 - const isSearching = searchQuery.trim().length > 0 - - // 검색 결과 표시 여부 (검색 타입에 따라) - const showCategoryResults = isSearching && searchType === 'all' && filteredCategories.length > 0 - const showGeneResults = isSearching && (searchType === 'all' || searchType === 'genes') && totalFilteredGenes.length > 0 - const showTraitResults = isSearching && (searchType === 'all' || searchType === 'traits') && filteredTraits.length > 0 - - // 카테고리 토글 - const toggleCategory = (category: 'quantity' | 'quality' | 'growth' | 'economic' | 'body' | 'weight' | 'rate') => { - setCategoryOpen(prev => ({ ...prev, [category]: !prev[category] })) + traitWeights: { ...prev.traitWeights, [traitName]: newValue } + } + }) } // 카테고리 전체 선택/해제 - const toggleCategoryAll = (category: 'quantity' | 'quality', select: boolean) => { - const genes = category === 'quantity' ? filteredQuantityGenes : filteredQualityGenes - const geneNames = genes.map(g => g.markerNm) - - setLocalFilters(prev => ({ - ...prev, - selectedGenes: select - ? [...new Set([...prev.selectedGenes, ...geneNames])] // 추가 - : prev.selectedGenes.filter(g => !geneNames.includes(g)) // 제거 - })) - } - - // 카테고리가 모두 선택되었는지 확인 - const isCategoryFullySelected = (categoryId: string): boolean => { - if (categoryId === 'quantity') { - const geneNames = quantityGenes.map(g => g.markerNm) - return geneNames.length > 0 && geneNames.every(name => localFilters.selectedGenes.includes(name)) - } else if (categoryId === 'quality') { - const geneNames = qualityGenes.map(g => g.markerNm) - return geneNames.length > 0 && geneNames.every(name => localFilters.selectedGenes.includes(name)) - } else if (categoryId === 'traits') { - // 경제형질은 가중치 합계가 100인지 확인 - return Object.values(localFilters.traitWeights).reduce((a, b) => a + b, 0) === 100 - } - return false - } - - // 카테고리 선택/해제 토글 (검색 결과에서) - const toggleCategorySelection = (categoryId: string) => { - if (categoryId === 'quantity' || categoryId === 'quality') { - const isSelected = isCategoryFullySelected(categoryId) - toggleCategoryAll(categoryId as 'quantity' | 'quality', !isSelected) - } else if (categoryId === 'traits') { - // 경제형질은 가중치가 있으므로 기본값 설정 - if (isCategoryFullySelected('traits')) { - // 모두 0으로 초기화 - setLocalFilters(prev => ({ - ...prev, - traitWeights: { ...prev.traitWeights, 도체중: 0, 등심단면적: 0, 등지방두께: 0, 근내지방도: 0 } - })) + const toggleCategoryGenes = (genes: MarkerModel[], select: boolean) => { + setLocalFilters(prev => { + if (select) { + const newGenes = [...prev.selectedGenes] + genes.forEach(g => { + if (!newGenes.includes(g.markerNm)) newGenes.push(g.markerNm) + }) + return { ...prev, selectedGenes: newGenes } } else { - // 균등 분배 (25씩) - setLocalFilters(prev => ({ + return { ...prev, - traitWeights: { ...prev.traitWeights, 도체중: 25, 등심단면적: 25, 등지방두께: 25, 근내지방도: 25 } - })) + selectedGenes: prev.selectedGenes.filter(id => !genes.some(g => g.markerNm === id)), + pinnedGenes: prev.pinnedGenes?.filter(id => !genes.some(g => g.markerNm === id)) || [] + } } - } + }) } + const toggleCategoryTraits = (traits: string[], select: boolean) => { + setLocalFilters(prev => { + const newWeights = { ...prev.traitWeights } + let newSelectedTraits = [...(prev.selectedTraits || [])] + + if (select) { + traits.forEach(t => { + if (!newSelectedTraits.includes(t)) { + newSelectedTraits.push(t) + newWeights[t as TraitName] = 5 + } + }) + } else { + traits.forEach(t => { + newWeights[t as TraitName] = 0 + }) + newSelectedTraits = newSelectedTraits.filter(t => !traits.includes(t)) + } + + return { + ...prev, + selectedTraits: newSelectedTraits, + traitWeights: newWeights, + pinnedTraits: select ? prev.pinnedTraits : prev.pinnedTraits?.filter(t => !traits.includes(t)) || [] + } + }) + } + + // 정렬: 고정된 것 먼저 + 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)) || []) + ] + return ( @@ -734,8 +292,7 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa className="h-8 md:h-9 text-xs md:text-base font-semibold px-2 md:px-3" > - 필터 - 필터 + 필터 {filters.isActive ? ( 활성 ) : ( @@ -743,1242 +300,530 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa )} - {/* 85vw → 92vw (7% 더 넓게), calc(100vw-14rem) → calc(100vw-10rem) (사이드바 여백 축소)*/} - + + {/* 헤더 */} -
+
- - + + 전역 필터 설정 - - 유전자와 유전체 형질을 선택하여 원하는 필터를 설정하세요 + + 유전자와 형질을 선택하세요
{/* 메인 콘텐츠 */} - -
- {/* 통합 검색창 */} -
-
- {/* 검색 타입 드롭다운 (왼쪽) */} - - - {/* 구분선 */} -
- - {/* 검색 아이콘 */} - - - {/* 검색 입력창 */} - setSearchQuery(e.target.value)} - className="flex-1 h-full text-sm sm:text-base md:text-lg border-0 border-none bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-0 shadow-none pl-2 sm:pl-3 pr-3 sm:pr-4" - /> -
+ +
+ {/* 유전자 선택 버튼 */} +
+
유전자
+
- {/* 선택한 필터 조건 요약 박스 - 유전자 또는 형질이 선택되었을 때 표시 */} - {(localFilters.selectedGenes.length > 0 || (localFilters.selectedTraits && localFilters.selectedTraits.length > 0)) && ( - - {/* 헤더 */} - -
-
- -

선택한 필터 조건

- - {localFilters.selectedGenes.length + (localFilters.selectedTraits?.length || 0)}개 - -
- {filterConditionOpen ? : } -
-
+ {/* 형질 선택 버튼 */} +
+
유전체 형질
+ +
- {/* 1열(모바일) / 2열(데스크톱) 레이아웃: 유전자 + 형질 */} - -
-
- {/* 1. 선택된 유전자 */} -
-
-
- - 유전자 - - {localFilters.selectedGenes.length}개 - -
- {localFilters.selectedGenes.length > 0 && ( - - )} -
+ {/* 선택한 조건 표시 */} + {(localFilters.selectedGenes.length > 0 || (localFilters.selectedTraits?.length || 0) > 0) && ( +
+

+ 선택한 조건 ({localFilters.selectedGenes.length + (localFilters.selectedTraits?.length || 0)}개) +

- {localFilters.selectedGenes.length > 0 ? ( - 0 && ( +
+
유전자
+
+ {sortedSelectedGenes.map(geneId => { + const isPinned = localFilters.pinnedGenes?.includes(geneId) + return ( +
- {/* 고정 영역 */} - -
-
- 📌 상위 고정 - - {(localFilters.pinnedGenes?.length || 0)}개 - -
- -
- - {(localFilters.pinnedGenes?.length || 0) > 0 ? ( - -
- {localFilters.pinnedGenes?.map((gene, index) => ( - { - setLocalFilters(prev => ({ - ...prev, - pinnedGenes: prev.pinnedGenes?.filter(g => g !== gene) - })) - }} - /> - ))} -
-
- ) : ( -
- 검색하여 고정할 유전자를 추가하세요 -
- )} -
- - - {activeId ? ( - - {activeId} - - ) : null} - - - ) : ( -
-

유전자를 선택해주세요

-
- )} -
- - {/* 2. 선택된 형질 (오른쪽) */} -
-
-
- - 형질 - - {(localFilters.selectedTraits?.length || 0)}개 - -
- {(localFilters.selectedTraits && localFilters.selectedTraits.length > 0) && ( - - )} -
- - {(localFilters.selectedTraits && localFilters.selectedTraits.length > 0) ? ( - - {/* 고정 영역 */} - -
-
- 📌 상위 고정 - - {(localFilters.pinnedTraits?.length || 0)}개 - -
- -
- - {(localFilters.pinnedTraits?.length || 0) > 0 ? ( - -
- {localFilters.pinnedTraits?.map((trait, index) => ( - { - setLocalFilters(prev => ({ - ...prev, - pinnedTraits: prev.pinnedTraits?.filter(t => t !== trait) - })) - }} - /> - ))} -
-
- ) : ( -
- 검색하여 고정할 형질을 추가하세요 -
- )} -
- - - {activeId ? ( - - {activeId} - - ) : null} - -
- ) : ( -
-

형질을 선택해주세요

+ + +
- )} -
+ ) + })}
- - + )} + + {/* 형질 */} + {sortedSelectedTraits.length > 0 && ( +
+
유전체 형질
+
+ {sortedSelectedTraits.map(traitId => { + const isPinned = localFilters.pinnedTraits?.includes(traitId) + return ( +
+ {isPinned && } + {traitId} + ({localFilters.traitWeights[traitId as TraitName] || 0}점) + + +
+ ) + })} +
+ + {/* 가중치 조절 */} +
+
가중치 조절
+ {sortedSelectedTraits.map(traitId => ( +
+ {traitId} +
+ + + {localFilters.traitWeights[traitId as TraitName] || 0}점 + + +
+
+ ))} +
+
+ )} +
)} +
+ - {/* 카테고리별 선택 (2열 레이아웃) */} -
-
- - + +
+ + {/* 유전자 선택 모달 */} + {showGeneModal && ( +
+
+
+

유전자 선택

+ -
- {/* 근친도 임계값 */} -
-
- {/* 왼쪽: 라벨 */} -
-
- % -
-
- -

낮을수록 유전적 다양성 확보

-
-
- - {/* 중앙: 슬라이더 - 모바일에서 전체 너비 */} -
- - setLocalFilters(prev => ({ ...prev, inbreedingThreshold: value[0] })) - } - min={0} - max={30} - step={0.5} - className="w-full h-10 sm:h-auto" - /> -
- 0% - 30% -
-
- - {/* 오른쪽: 값 표시 */} -
- { - const input = e.target.value - // 숫자와 소수점만 허용 - if (input === '' || /^\d*\.?\d*$/.test(input)) { - const val = input === '' ? 0 : parseFloat(input) - if (!isNaN(val) && val >= 0 && val <= 30) { - setLocalFilters(prev => ({ - ...prev, - inbreedingThreshold: val - })) - } else if (input === '' || input === '.') { - // 빈 값이거나 소수점만 입력 중인 경우 일시적으로 허용 - setLocalFilters(prev => ({ - ...prev, - inbreedingThreshold: 0 - })) - } - } - }} - onBlur={() => { - // blur 시 범위 체크 및 정리 - const val = Math.min(30, Math.max(0, localFilters.inbreedingThreshold)) - setLocalFilters(prev => ({ - ...prev, - inbreedingThreshold: val - })) - }} - className="w-22 sm:w-28 h-12 sm:h-14 text-center text-xl sm:text-2xl font-bold border border-slate-200" - /> - % -
-
+ +
- {loadingGenes ? ( -
- +
+ {/* 검색창 */} +
+ + setGeneSearch(e.target.value)} + />
- ) : ( -
- {/* 왼쪽: 유전자 (육량형 + 육질형) */} -
-
-
- - -
- -
- {/* 육량형 Collapsible */} - toggleCategory('quantity')} - > - {(() => { - const selectedCount = filteredQuantityGenes.filter(g => - localFilters.selectedGenes.includes(g.markerNm) - ).length - const hasSelected = selectedCount > 0 - - return ( -
-
- {/* 왼쪽: 체크박스 + 라벨 */} -
- 0} - onCheckedChange={(checked) => { - toggleCategoryAll('quantity', !!checked) - }} - className="h-4 w-4 sm:h-5 sm:w-5 cursor-pointer" - /> - 육량형 - - {selectedCount}/{filteredQuantityGenes.length}개 - -
- - {/* 오른쪽: 아코디언 토글 버튼 */} - - - -
- - - - {filteredQuantityGenes.length === 0 ? ( -
- 검색 결과가 없습니다 -
- ) : ( -
- {filteredQuantityGenes.map((gene) => { - const isSelected = localFilters.selectedGenes.includes(gene.markerNm) - return ( -
-
- toggleGene(gene.markerNm)} - className="h-5 w-5 sm:h-6 sm:w-6 cursor-pointer" - /> -
toggleGene(gene.markerNm)}> -

{gene.markerNm}

- {gene.relatedTrait && ( -

{gene.relatedTrait}

- )} -
-
-
- ) - })} -
- )} -
-
-
- ) - })()} -
- - {/* 육질형 Collapsible */} - toggleCategory('quality')} - > - {(() => { - const selectedCount = filteredQualityGenes.filter(g => - localFilters.selectedGenes.includes(g.markerNm) - ).length - const hasSelected = selectedCount > 0 - - return ( -
-
- {/* 왼쪽: 체크박스 + 라벨 */} -
- 0} - onCheckedChange={(checked) => { - toggleCategoryAll('quality', !!checked) - }} - className="h-4 w-4 sm:h-5 sm:w-5 cursor-pointer" - /> - 육질형 - - {selectedCount}/{filteredQualityGenes.length}개 - -
- - {/* 오른쪽: 아코디언 토글 버튼 */} - - - -
- - - - {filteredQualityGenes.length === 0 ? ( -
- 검색 결과가 없습니다 -
- ) : ( -
- {filteredQualityGenes.map((gene) => { - const isSelected = localFilters.selectedGenes.includes(gene.markerNm) - return ( -
-
- toggleGene(gene.markerNm)} - className="h-5 w-5 sm:h-6 sm:w-6 cursor-pointer" - /> -
toggleGene(gene.markerNm)}> -

{gene.markerNm}

- {gene.relatedTrait && ( -

{gene.relatedTrait}

- )} -
-
-
- ) - })} -
- )} -
-
-
- ) - })()} -
+ {loadingGenes ? ( +
+
+ ) : ( +
+ {/* 육량형 */} + {(() => { + const genes = quantityGenes + const isCategoryMatch = geneSearch && '육량형'.includes(geneSearch.toLowerCase()) + const filteredGenes = geneSearch + ? isCategoryMatch + ? genes + : genes.filter(g => + g.markerNm.toLowerCase().includes(geneSearch.toLowerCase()) || + (g.relatedTrait && g.relatedTrait.toLowerCase().includes(geneSearch.toLowerCase())) + ) + : genes - {/* 오른쪽: 경제형질 + 가중치 */} -
-
- - -
+ if (geneSearch && filteredGenes.length === 0) return null - {/* 형질 카테고리별 Collapsible */} - {Object.entries(traitCategories).map(([key, category]) => { - // 검색 필터링 - 카테고리 이름 매칭 시 전체 표시 - const query = searchQuery.trim().toLowerCase() - const isCategoryNameMatch = category.name.toLowerCase().includes(query) - - const filteredCategoryTraits = searchQuery.trim() - ? isCategoryNameMatch - ? (category.traits as unknown as string[]) // 카테고리 이름 매칭 시 전체 표시 - : (category.traits as unknown as string[]).filter(trait => - trait.toLowerCase().includes(query) - ) - : (category.traits as unknown as string[]) - - // 선택된 형질 개수 계산 (가중치 > 0) - const selectedTraitCount = filteredCategoryTraits.filter(trait => - (localFilters.traitWeights[trait as TraitName] ?? 0) > 0 - ).length - const hasTraitSelected = selectedTraitCount > 0 + const selectedCount = filteredGenes.filter(g => localFilters.selectedGenes.includes(g.markerNm)).length + const isAllSelected = selectedCount === filteredGenes.length && filteredGenes.length > 0 + const hasSelected = selectedCount > 0 + const isExpanded = geneSearch ? true : (openCategories['quantity'] ?? false) return ( 0)} - onOpenChange={() => toggleCategory(key as any)} + open={isExpanded} + onOpenChange={(open) => setOpenCategories(prev => ({ ...prev, quantity: open }))} > -
-
- {/* 왼쪽: 체크박스 + 라벨 */} -
+
+
+
0} - onCheckedChange={(checked) => { - // 체크: 모든 형질 가중치를 5로 설정, 언체크: 0으로 설정 - const newWeights = { ...localFilters.traitWeights } - filteredCategoryTraits.forEach((trait: string) => { - newWeights[trait as TraitName] = checked ? 5 : 0 - }) - - // selectedTraits 업데이트 - const currentSelected = localFilters.selectedTraits || [] - let newSelectedTraits: string[] - - if (checked) { - // 체크: 카테고리의 형질들을 추가 (중복 제거) - const traitsToAdd = filteredCategoryTraits.filter(t => !currentSelected.includes(t)) - newSelectedTraits = [...currentSelected, ...traitsToAdd] - } else { - // 언체크: 카테고리의 형질들을 제거 - newSelectedTraits = currentSelected.filter(t => !filteredCategoryTraits.includes(t)) - } - - setLocalFilters(prev => ({ - ...prev, - traitWeights: newWeights, - selectedTraits: newSelectedTraits - })) - }} - className="h-4 w-4 sm:h-5 sm:w-5 cursor-pointer" + checked={isAllSelected} + onCheckedChange={(checked) => toggleCategoryGenes(filteredGenes, !!checked)} + className="h-4 w-4" /> - {category.name} - - {selectedTraitCount}/{filteredCategoryTraits.length}개 + + 육량형 + + + {selectedCount}/{filteredGenes.length}
- - {/* 오른쪽: 아코디언 토글 버튼 */} -
- - {filteredCategoryTraits.length === 0 ? ( -
- 검색 결과가 없습니다 -
- ) : ( -
- {filteredCategoryTraits.map((trait: string) => { - const labels = traitLabels[trait] || { left: '낮게', right: '높게' } - const description = traitDescriptions[trait] || '' - const isTraitSelected = (localFilters.traitWeights[trait as TraitName] ?? 0) > 0 - return ( -
- {/* 모바일: 체크박스 + 라벨 + 중요도 컨트롤을 한 줄에 */} -
-
- { - if (checked) { - updateTraitWeight(trait as TraitName, 5) - } else { - updateTraitWeight(trait as TraitName, 0) - } - }} - className="h-5 w-5 sm:h-6 sm:w-6 cursor-pointer" - /> - - {description && ( - - - - - - -

{description}

-
-
-
- )} -
- {/* 모바일: 숫자만 표시 */} -
- - {localFilters.traitWeights[trait as TraitName] ?? 0} - - /10 -
-
- {/* 모바일: 슬라이더 스타일 버튼 */} -
- -
-
-
- -
- {/* 데스크톱: 기존 레이아웃 */} -
- 중요도 - - { - const value = parseInt(e.target.value) - if (isNaN(value)) { - updateTraitWeight(trait as TraitName, 0) - } else { - const clamped = Math.min(10, Math.max(0, value)) - updateTraitWeight(trait as TraitName, clamped) - } - }} - className={`text-sm font-bold w-12 h-8 text-center rounded-lg px-1 transition-colors ${(localFilters.traitWeights[trait as TraitName] ?? 0) > 0 - ? 'bg-primary text-primary-foreground' - : 'bg-slate-100 text-slate-600' - }`} - /> - / 10 - - -
+
+ {filteredGenes.map(gene => { + const isSelected = localFilters.selectedGenes.includes(gene.markerNm) + return ( +
toggleGene(gene.markerNm)} + > +
+ +
+ {gene.markerNm} + {gene.relatedTrait && ( + {gene.relatedTrait} + )}
- ) - })} -
- )} - +
+
+ ) + })} +
) - })} + })()} + {/* 육질형 */} + {(() => { + const genes = qualityGenes + const isCategoryMatch = geneSearch && '육질형'.includes(geneSearch.toLowerCase()) + const filteredGenes = geneSearch + ? isCategoryMatch + ? genes + : genes.filter(g => + g.markerNm.toLowerCase().includes(geneSearch.toLowerCase()) || + (g.relatedTrait && g.relatedTrait.toLowerCase().includes(geneSearch.toLowerCase())) + ) + : genes + + if (geneSearch && filteredGenes.length === 0) return null + + const selectedCount = filteredGenes.filter(g => localFilters.selectedGenes.includes(g.markerNm)).length + const isAllSelected = selectedCount === filteredGenes.length && filteredGenes.length > 0 + const hasSelected = selectedCount > 0 + const isExpanded = geneSearch ? true : (openCategories['quality'] ?? false) + + return ( + setOpenCategories(prev => ({ ...prev, quality: open }))} + > +
+
+
+ toggleCategoryGenes(filteredGenes, !!checked)} + className="h-4 w-4" + /> + + 육질형 + + + {selectedCount}/{filteredGenes.length} + +
+ + + +
+ +
+ {filteredGenes.map(gene => { + const isSelected = localFilters.selectedGenes.includes(gene.markerNm) + return ( +
toggleGene(gene.markerNm)} + > +
+ +
+ {gene.markerNm} + {gene.relatedTrait && ( + {gene.relatedTrait} + )} +
+
+
+ ) + })} +
+
+
+
+ ) + })()}
-
- )} -
-
- + )} +
- {/* 변경사항 안내 배너 */} - {hasChanges && ( -
-
- - - -

- 변경사항이 저장되지 않았습니다. 적용 버튼을 눌러 저장하세요. -

+
+ + +
)} - {/* 버튼 영역 */} -
- -
- - + {/* 형질 선택 모달 */} + {showTraitModal && ( +
+
+
+

유전체 형질 선택

+ +
+ +
+ {/* 검색창 */} +
+ + setTraitSearch(e.target.value)} + /> +
+ + {/* 형질 목록 - 카테고리별 아코디언 */} +
+ {TRAIT_CATEGORIES.map(cat => { + const isCategoryMatch = traitSearch && cat.name.toLowerCase().includes(traitSearch.toLowerCase()) + const filteredTraits = traitSearch + ? isCategoryMatch + ? cat.traits + : cat.traits.filter(t => + t.toLowerCase().includes(traitSearch.toLowerCase()) || + (TRAIT_DESCRIPTIONS[t] && TRAIT_DESCRIPTIONS[t].toLowerCase().includes(traitSearch.toLowerCase())) + ) + : cat.traits + + if (traitSearch && filteredTraits.length === 0) return null + + const selectedCount = filteredTraits.filter(t => localFilters.selectedTraits?.includes(t)).length + const isAllSelected = selectedCount === filteredTraits.length && filteredTraits.length > 0 + const hasSelected = selectedCount > 0 + const isExpanded = traitSearch ? true : (openCategories[cat.id] ?? false) + + return ( + setOpenCategories(prev => ({ ...prev, [cat.id]: open }))} + > +
+
+
+ toggleCategoryTraits(filteredTraits, !!checked)} + className="h-4 w-4" + /> + + {cat.name} + + + {selectedCount}/{filteredTraits.length} + +
+ + + +
+ +
+ {filteredTraits.map(trait => { + const isSelected = localFilters.selectedTraits?.includes(trait) + return ( +
+
toggleTrait(trait)} + > + +
+ {trait} + {TRAIT_DESCRIPTIONS[trait] && ( + {TRAIT_DESCRIPTIONS[trait]} + )} +
+
+ {isSelected && ( +
+ 가중치 +
+ + + {localFilters.traitWeights[trait as TraitName] || 0}점 + + +
+
+ )} +
+ ) + })} +
+
+
+
+ ) + })} +
+
+ +
+ + +
+
-
+ )} - - {/* 유전자 검색 모달 */} - setLocalFilters(prev => ({ ...prev, selectedGenes: genes }))} - /> - - {/* 고정 항목 추가 모달 */} - - - - - {pinnedSearchType === 'gene' ? '📌 고정 유전자 추가' : '📌 고정 형질 추가'} - - - {pinnedSearchType === 'gene' - ? '검색하여 고정할 유전자를 선택하세요 (순서 변경 가능)' - : '검색하여 고정할 형질을 선택하세요 (순서 변경 가능)' - } - - - -
- {/* 검색 입력 */} -
- - setPinnedSearchQuery(e.target.value)} - className="pl-9" - /> -
- - {/* 현재 고정된 항목 수 표시 */} -
- 현재 고정: {pinnedSearchType === 'gene' - ? `${localFilters.pinnedGenes?.length || 0}개` - : `${localFilters.pinnedTraits?.length || 0}개` - } -
- - {/* 검색 결과 리스트 */} - - {(() => { - if (pinnedSearchType === 'gene') { - // 유전자 검색 - const pinnedSet = new Set(localFilters.pinnedGenes || []) - const searchResults = localFilters.selectedGenes - .filter(gene => !pinnedSet.has(gene)) - .filter(gene => { - if (!pinnedSearchQuery.trim()) return true - return gene.toLowerCase().includes(pinnedSearchQuery.toLowerCase()) - }) - - if (searchResults.length === 0) { - return ( -
- {pinnedSearchQuery.trim() - ? '검색 결과가 없습니다' - : '고정 가능한 유전자가 없습니다' - } -
- ) - } - - return ( -
- {searchResults.map(gene => ( - - ))} -
- ) - } else { - // 형질 검색 - const pinnedSet = new Set(localFilters.pinnedTraits || []) - const allTraitNames = Object.keys(localFilters.traitWeights) - const selectedTraitSet = new Set(localFilters.selectedTraits || []) - - const searchResults = allTraitNames - .filter(trait => selectedTraitSet.has(trait)) - .filter(trait => !pinnedSet.has(trait)) - .filter(trait => { - if (!pinnedSearchQuery.trim()) return true - return trait.toLowerCase().includes(pinnedSearchQuery.toLowerCase()) - }) - - if (searchResults.length === 0) { - return ( -
- {pinnedSearchQuery.trim() - ? '검색 결과가 없습니다' - : '고정 가능한 형질이 없습니다' - } -
- ) - } - - return ( -
- {searchResults.map(trait => ( - - ))} -
- ) - } - })()} -
-
-
-
) } - -// Draggable Gene Item (카테고리용) -function DraggableGeneItem({ gene, isSelected, onToggle }: { - gene: MarkerModel - isSelected: boolean - onToggle: () => void -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: gene.markerNm }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.3 : 1, - } - - return ( -
-
- - -
-

{gene.markerNm}

- {gene.relatedTrait && ( -

{gene.relatedTrait}

- )} -
-
-
- ) -} - -// Sortable Badge 컴포넌트 -function SortableGeneBadge({ gene, onRemove }: { - gene: string - onRemove: () => void -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: gene }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - } - - return ( -
- -
- -
- {gene} - -
-
- ) -} - -// Sortable 형질 Badge 컴포넌트 (초록색 테마) -function SortableTraitBadge({ trait, weight, onRemove }: { - trait: string - weight: number - onRemove: () => void -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: trait }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - } - - return ( -
- -
- -
- {trait} - -
-
- ) -} - -// Sortable 고정 유전자 아이템 -function SortablePinnedGeneItem({ gene, index, onRemove }: { - gene: string - index: number - onRemove: () => void -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: gene }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - } - - return ( -
-
- - {index + 1}. - {gene} -
- -
- ) -} - -// Sortable 고정 형질 아이템 -function SortablePinnedTraitItem({ trait, index, weight, onRemove }: { - trait: string - index: number - weight: number - onRemove: () => void -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: trait }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - } - - return ( -
-
- - {index + 1}. - {trait} -
- -
- ) -} - -// 드롭 가능 영역 컴포넌트 -function DroppableArea({ id, children, className }: { - id: string - children: React.ReactNode - className?: string -}) { - const { setNodeRef, isOver } = useDroppable({ - id: id, - }) - - return ( -
- {children} -
- ) -}