перетаскивание эталонов

This commit is contained in:
2025-07-28 07:52:18 +03:00
parent de5b60907b
commit 0a77199642

View File

@@ -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>
)} )}