перетаскивание эталонов
This commit is contained in:
@@ -9,10 +9,28 @@ import {
|
|||||||
import { Input } from '@/component/ui/input'
|
import { Input } from '@/component/ui/input'
|
||||||
import { Separator } from '@/component/ui/separator'
|
import { Separator } from '@/component/ui/separator'
|
||||||
import { useToast } from '@/lib/hooks/useToast'
|
import { useToast } from '@/lib/hooks/useToast'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
closestCenter,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
|
GripVertical,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
X,
|
X,
|
||||||
@@ -21,6 +39,89 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { useStandards } from './hooks'
|
import { useStandards } from './hooks'
|
||||||
import { Standard } from './types'
|
import { Standard } from './types'
|
||||||
|
|
||||||
|
// Компонент для сортируемого элемента эталона
|
||||||
|
interface SortableStandardItemProps {
|
||||||
|
standard: Standard
|
||||||
|
onRemove: (standardId: string) => void
|
||||||
|
isDragActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableStandardItem({
|
||||||
|
standard,
|
||||||
|
onRemove,
|
||||||
|
isDragActive,
|
||||||
|
}: SortableStandardItemProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: standard.id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition: isDragging ? 'none' : transition,
|
||||||
|
opacity: isDragging ? 0.9 : 1,
|
||||||
|
zIndex: isDragging ? 1000 : 'auto',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className={`flex cursor-grab select-none items-center gap-3 rounded-md border border-primary bg-primary/10 px-3 py-2 shadow-sm ${
|
||||||
|
isDragging
|
||||||
|
? 'scale-105 shadow-lg'
|
||||||
|
: !isDragActive
|
||||||
|
? 'transition-shadow duration-200 hover:shadow-md'
|
||||||
|
: ''
|
||||||
|
} active:cursor-grabbing`}
|
||||||
|
>
|
||||||
|
<div className="flex h-6 w-6 items-center justify-center text-muted-foreground">
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="min-w-20 shrink-0 text-sm font-medium text-foreground">
|
||||||
|
{standard.protocol_name}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-sm text-muted-foreground">
|
||||||
|
{standard.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FileText className="h-3 w-3" />
|
||||||
|
<span className="hidden sm:inline">{standard.registry_number}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="px-1.5 py-0.5 text-xs">
|
||||||
|
{standard.range}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 shrink-0 p-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRemove(standard.id)
|
||||||
|
}}
|
||||||
|
onPointerDown={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface StandardsSelectorProps {
|
interface StandardsSelectorProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -37,12 +138,51 @@ export function StandardsSelector({
|
|||||||
maxItems = 7, // Значение по умолчанию
|
maxItems = 7, // Значение по умолчанию
|
||||||
}: StandardsSelectorProps) {
|
}: StandardsSelectorProps) {
|
||||||
const [selectedStandards, setSelectedStandards] = useState<string[]>(value)
|
const [selectedStandards, setSelectedStandards] = useState<string[]>(value)
|
||||||
|
const [isDragActive, setIsDragActive] = useState(false)
|
||||||
|
|
||||||
|
// Синхронизируем selectedStandards с value при изменении value
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedStandards(value)
|
||||||
|
}, [value])
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const { error } = useToast()
|
const { error } = useToast()
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const { data: standards = [], isLoading, error: apiError } = useStandards()
|
const { data: standards = [], isLoading, error: apiError } = useStandards()
|
||||||
|
|
||||||
|
// Настройка сенсоров для drag-and-drop
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 10, // Начинать перетаскивание после движения на 10px
|
||||||
|
tolerance: 5,
|
||||||
|
delay: 100,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Обработчик начала перетаскивания
|
||||||
|
const handleDragStart = () => {
|
||||||
|
setIsDragActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик завершения перетаскивания
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
setIsDragActive(false)
|
||||||
|
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
setSelectedStandards(items => {
|
||||||
|
const oldIndex = items.indexOf(active.id as string)
|
||||||
|
const newIndex = items.indexOf(over.id as string)
|
||||||
|
return arrayMove(items, oldIndex, newIndex)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Автофокус на поле поиска при открытии
|
// Автофокус на поле поиска при открытии
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && searchInputRef.current) {
|
if (isOpen && searchInputRef.current) {
|
||||||
@@ -119,9 +259,10 @@ export function StandardsSelector({
|
|||||||
)
|
)
|
||||||
}, [searchTerm, standards])
|
}, [searchTerm, standards])
|
||||||
|
|
||||||
const selectedStandardsData = standards.filter(standard =>
|
// Создаем массив выбранных эталонов в том порядке, как они выбраны
|
||||||
selectedStandards.includes(standard.id)
|
const selectedStandardsData = selectedStandards
|
||||||
)
|
.map(standardId => standards.find(standard => standard.id === standardId))
|
||||||
|
.filter((standard): standard is Standard => standard !== undefined)
|
||||||
|
|
||||||
const unselectedStandards = filteredStandards.filter(
|
const unselectedStandards = filteredStandards.filter(
|
||||||
standard => !selectedStandards.includes(standard.id)
|
standard => !selectedStandards.includes(standard.id)
|
||||||
@@ -346,11 +487,31 @@ export function StandardsSelector({
|
|||||||
<div>
|
<div>
|
||||||
<h4 className="mb-3 text-sm font-medium text-foreground">
|
<h4 className="mb-3 text-sm font-medium text-foreground">
|
||||||
Выбранные эталоны ({selectedStandardsData.length})
|
Выбранные эталоны ({selectedStandardsData.length})
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
Перетащите для изменения порядка
|
||||||
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
<div className="max-h-64 space-y-1 overflow-y-auto">
|
<div className="max-h-64 space-y-1 overflow-y-auto">
|
||||||
{selectedStandardsData.map(standard =>
|
<DndContext
|
||||||
renderStandardCard(standard, true, true)
|
sensors={sensors}
|
||||||
)}
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={selectedStandards}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{selectedStandardsData.map(standard => (
|
||||||
|
<SortableStandardItem
|
||||||
|
key={standard.id}
|
||||||
|
standard={standard}
|
||||||
|
onRemove={removeStandard}
|
||||||
|
isDragActive={isDragActive}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user