группы
This commit is contained in:
@@ -43,7 +43,6 @@
|
|||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-animated-numbers": "^1.1.1",
|
"react-animated-numbers": "^1.1.1",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
|
||||||
"react-day-picker": "^9.8.0",
|
"react-day-picker": "^9.8.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-grid-layout": "^1.5.2",
|
"react-grid-layout": "^1.5.2",
|
||||||
|
|||||||
191
src/component/TemplateManager/GroupManager.tsx
Normal file
191
src/component/TemplateManager/GroupManager.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { useGroupContext } from '@/entity/group/model/GroupContext'
|
||||||
|
import { Group } from '@/type/template'
|
||||||
|
import { Edit3, FolderPlus, Plus, Tag } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from 'shared/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from 'shared/ui/card'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from 'shared/ui/dialog'
|
||||||
|
import { Input } from 'shared/ui/input'
|
||||||
|
import { Label } from 'shared/ui/label'
|
||||||
|
import { Separator } from 'shared/ui/separator'
|
||||||
|
|
||||||
|
interface GroupFormProps {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
group?: Group
|
||||||
|
onSubmit: (name: string) => void | Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
submitText: string
|
||||||
|
submitIcon: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupForm: React.FC<GroupFormProps> = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
group,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
submitText,
|
||||||
|
submitIcon,
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState(group?.name || '')
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (name.trim()) {
|
||||||
|
onSubmit(name.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader className="pb-4">
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||||
|
{icon}
|
||||||
|
{title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag className="h-4 w-4 text-blue-600" />
|
||||||
|
<CardTitle className="text-sm">Название группы</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="group-name">Название</Label>
|
||||||
|
<Input
|
||||||
|
id="group-name"
|
||||||
|
placeholder="Введите название группы"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{!name.trim() && (
|
||||||
|
<span className="text-amber-600">Заполните название группы</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Отмена</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!name.trim()}
|
||||||
|
className="min-w-[130px]"
|
||||||
|
>
|
||||||
|
{submitIcon}
|
||||||
|
{submitText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupManagerProps {
|
||||||
|
onGroupCreated?: (groupId: string) => void
|
||||||
|
editingGroupId?: string | null
|
||||||
|
onEditingGroupChange?: (groupId: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupManager: React.FC<GroupManagerProps> = ({
|
||||||
|
onGroupCreated,
|
||||||
|
editingGroupId,
|
||||||
|
onEditingGroupChange,
|
||||||
|
}) => {
|
||||||
|
const { groups, addGroup, updateGroup } = useGroupContext()
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||||
|
|
||||||
|
// Получаем группу для редактирования из пропсов
|
||||||
|
const editingGroup = editingGroupId ? groups.find(g => g.id === editingGroupId) || null : null
|
||||||
|
|
||||||
|
const handleCreate = async (name: string) => {
|
||||||
|
try {
|
||||||
|
const newGroup = await addGroup({ name })
|
||||||
|
setIsCreateOpen(false)
|
||||||
|
onGroupCreated?.(newGroup.id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при создании группы:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = async (name: string) => {
|
||||||
|
if (editingGroup) {
|
||||||
|
try {
|
||||||
|
await updateGroup(editingGroup.id, { name })
|
||||||
|
onEditingGroupChange?.(null)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при редактировании группы:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
onEditingGroupChange?.(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Кнопка создания новой группы */}
|
||||||
|
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
<FolderPlus className="mr-1 h-4 w-4" />
|
||||||
|
Новая группа
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden">
|
||||||
|
<GroupForm
|
||||||
|
title="Создать новую группу"
|
||||||
|
description="Создайте группу для организации ваших шаблонов"
|
||||||
|
icon={<FolderPlus className="h-5 w-5 text-blue-600" />}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
onCancel={() => setIsCreateOpen(false)}
|
||||||
|
submitText="Создать"
|
||||||
|
submitIcon={<Plus className="mr-2 h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Диалог редактирования группы */}
|
||||||
|
<Dialog open={!!editingGroup} onOpenChange={handleCancelEdit}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden">
|
||||||
|
<GroupForm
|
||||||
|
title="Редактировать группу"
|
||||||
|
description="Измените название группы"
|
||||||
|
icon={<Edit3 className="h-5 w-5 text-orange-600" />}
|
||||||
|
group={editingGroup || undefined}
|
||||||
|
onSubmit={handleEdit}
|
||||||
|
onCancel={handleCancelEdit}
|
||||||
|
submitText="Сохранить"
|
||||||
|
submitIcon={<Edit3 className="mr-2 h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GroupManager
|
||||||
132
src/component/TemplateManager/OrderSaveButton.tsx
Normal file
132
src/component/TemplateManager/OrderSaveButton.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
|
||||||
|
interface OrderSaveButtonProps {
|
||||||
|
hasUnsavedChanges: boolean
|
||||||
|
isSaving: boolean
|
||||||
|
lastSaveError: string | null
|
||||||
|
onSave: () => Promise<void>
|
||||||
|
onClearError: () => void
|
||||||
|
changedCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OrderSaveButton = memo(
|
||||||
|
({
|
||||||
|
hasUnsavedChanges,
|
||||||
|
isSaving,
|
||||||
|
lastSaveError,
|
||||||
|
onSave,
|
||||||
|
onClearError,
|
||||||
|
changedCount = 0,
|
||||||
|
}: OrderSaveButtonProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Ошибка сохранения */}
|
||||||
|
{lastSaveError && (
|
||||||
|
<div className="flex items-center gap-1 rounded-md bg-destructive/10 px-2 py-1">
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs text-destructive" title={lastSaveError}>
|
||||||
|
Ошибка сохранения порядка
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onClearError}
|
||||||
|
className="ml-1 text-destructive/60 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Индикатор сохранения */}
|
||||||
|
{isSaving && (
|
||||||
|
<div className="flex items-center text-muted-foreground">
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5 animate-spin"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<span className="ml-1 text-xs">Сохранение порядка...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопка сохранения порядка */}
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving || !hasUnsavedChanges}
|
||||||
|
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${
|
||||||
|
hasUnsavedChanges
|
||||||
|
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||||
|
: lastSaveError
|
||||||
|
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
title={
|
||||||
|
lastSaveError
|
||||||
|
? 'Повторить сохранение порядка'
|
||||||
|
: hasUnsavedChanges
|
||||||
|
? `Сохранить изменения порядка (${changedCount} шаблонов)`
|
||||||
|
: 'Порядок сохранен'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{lastSaveError
|
||||||
|
? 'Повторить'
|
||||||
|
: hasUnsavedChanges
|
||||||
|
? `Сохранить порядок (${changedCount})`
|
||||||
|
: 'Порядок сохранен'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
OrderSaveButton.displayName = 'OrderSaveButton'
|
||||||
431
src/component/TemplateManager/TemplateGroup.tsx
Normal file
431
src/component/TemplateManager/TemplateGroup.tsx
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
import { Group, Template } from '@/type/template'
|
||||||
|
import TemplateCard from '@/widget/template/ui/TemplateCard'
|
||||||
|
import { useDraggable, useDroppable } from '@dnd-kit/core'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Edit3,
|
||||||
|
FolderClosed,
|
||||||
|
FolderOpen,
|
||||||
|
MoreVertical,
|
||||||
|
Plus,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { Badge } from 'shared/ui/badge'
|
||||||
|
import { Button } from 'shared/ui/button'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from 'shared/ui/popover'
|
||||||
|
|
||||||
|
interface DraggableTemplateCardProps {
|
||||||
|
template: Template
|
||||||
|
index: number
|
||||||
|
selectionMode: boolean
|
||||||
|
isSelected: boolean
|
||||||
|
onToggleSelect: (id: string) => void
|
||||||
|
onCopy: (templateId: string) => void
|
||||||
|
onEdit: (templateId: string) => void
|
||||||
|
onDelete: (templateId: string) => void
|
||||||
|
activeFilters: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компонент для drop зоны (полоска)
|
||||||
|
interface DropZoneProps {
|
||||||
|
id: string
|
||||||
|
position: 'left' | 'right'
|
||||||
|
isVisible?: boolean
|
||||||
|
isDragActive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropZone: React.FC<DropZoneProps> = ({ id, position, isVisible = true, isDragActive = false }) => {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isVisible) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={clsx(
|
||||||
|
'absolute top-0 bottom-0 w-3 z-10',
|
||||||
|
position === 'left' ? '-left-3' : '-right-3',
|
||||||
|
'transition-all duration-200 ease-in-out',
|
||||||
|
// Базовое состояние - полностью прозрачная зона
|
||||||
|
'bg-transparent',
|
||||||
|
// При drag over показываем активную зону
|
||||||
|
isOver && 'bg-primary/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Активный индикатор только при drag over */}
|
||||||
|
{isOver && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className={clsx(
|
||||||
|
'h-full w-1 bg-primary rounded-full shadow-sm',
|
||||||
|
'animate-in fade-in duration-200',
|
||||||
|
// Добавляем тонкий border для лучшей видимости
|
||||||
|
'ring-1 ring-primary/20'
|
||||||
|
)}>
|
||||||
|
{/* Индикатор направления вставки */}
|
||||||
|
<div className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 left-1/2">
|
||||||
|
<div className={clsx(
|
||||||
|
'w-2 h-2 bg-primary rounded-full',
|
||||||
|
'border border-background shadow-sm',
|
||||||
|
'animate-pulse'
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновленный компонент карточки только с draggable
|
||||||
|
const DraggableDroppableTemplateCard: React.FC<DraggableTemplateCardProps> = ({
|
||||||
|
template,
|
||||||
|
index,
|
||||||
|
selectionMode,
|
||||||
|
isSelected,
|
||||||
|
onToggleSelect,
|
||||||
|
onCopy,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
activeFilters,
|
||||||
|
}) => {
|
||||||
|
// Только для перетаскивания
|
||||||
|
const {
|
||||||
|
attributes: dragAttributes,
|
||||||
|
listeners: dragListeners,
|
||||||
|
setNodeRef: setDragRef,
|
||||||
|
transform: dragTransform,
|
||||||
|
isDragging,
|
||||||
|
} = useDraggable({
|
||||||
|
id: template.id,
|
||||||
|
disabled: selectionMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: dragTransform ? `translate3d(${dragTransform.x}px, ${dragTransform.y}px, 0)` : undefined,
|
||||||
|
zIndex: isDragging ? 1000 : 'auto',
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Drop зона слева */}
|
||||||
|
<DropZone id={`drop-left-${template.id}`} position="left" />
|
||||||
|
|
||||||
|
{/* Основная карточка */}
|
||||||
|
<div
|
||||||
|
ref={setDragRef}
|
||||||
|
style={style}
|
||||||
|
{...dragAttributes}
|
||||||
|
{...dragListeners}
|
||||||
|
className={clsx(
|
||||||
|
'transition-shadow duration-200 cursor-grab active:cursor-grabbing relative',
|
||||||
|
isDragging && 'shadow-xl'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TemplateCard
|
||||||
|
template={template}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onToggleSelect={onToggleSelect}
|
||||||
|
onCopy={onCopy}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
activeFilters={activeFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drop зона справа */}
|
||||||
|
<DropZone id={`drop-right-${template.id}`} position="right" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Оставляем старый компонент для совместимости
|
||||||
|
const DraggableTemplateCard = DraggableDroppableTemplateCard
|
||||||
|
|
||||||
|
// Компонент для droppable зоны группы
|
||||||
|
interface DroppableGroupProps {
|
||||||
|
groupId: string
|
||||||
|
templates: Template[]
|
||||||
|
selectionMode: boolean
|
||||||
|
selectedTemplates: Set<string>
|
||||||
|
onToggleSelect: (id: string) => void
|
||||||
|
onCopy: (templateId: string) => void
|
||||||
|
onEdit: (templateId: string) => void
|
||||||
|
onDelete: (templateId: string) => void
|
||||||
|
activeFilters: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DroppableGroup: React.FC<DroppableGroupProps> = ({
|
||||||
|
groupId,
|
||||||
|
templates,
|
||||||
|
selectionMode,
|
||||||
|
selectedTemplates,
|
||||||
|
onToggleSelect,
|
||||||
|
onCopy,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
activeFilters,
|
||||||
|
}) => {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
|
id: groupId,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={clsx(
|
||||||
|
'transition-colors duration-200 relative',
|
||||||
|
templates.length === 0
|
||||||
|
? 'min-h-[120px] flex items-center justify-center'
|
||||||
|
: 'grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6',
|
||||||
|
isOver && templates.length === 0 && 'bg-primary/5 rounded-lg'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{templates.length > 0 ? (
|
||||||
|
templates.map((template, index) => (
|
||||||
|
<DraggableTemplateCard
|
||||||
|
key={template.id}
|
||||||
|
template={template}
|
||||||
|
index={index}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
isSelected={selectedTemplates.has(template.id)}
|
||||||
|
onToggleSelect={onToggleSelect}
|
||||||
|
onCopy={onCopy}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
activeFilters={activeFilters}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
/* Минималистичный placeholder для пустой группы */
|
||||||
|
<div className={clsx(
|
||||||
|
'flex items-center justify-center py-12 text-center rounded-lg transition-all duration-300',
|
||||||
|
'border border-dashed border-muted-foreground/15',
|
||||||
|
isOver
|
||||||
|
? 'border-primary/50 bg-primary/5 scale-[1.02]'
|
||||||
|
: 'hover:border-muted-foreground/25 hover:bg-muted/20'
|
||||||
|
)}>
|
||||||
|
{isOver ? (
|
||||||
|
<div className={clsx(
|
||||||
|
'flex items-center gap-3 px-6 py-3 rounded-md',
|
||||||
|
'bg-background/90 backdrop-blur-sm',
|
||||||
|
'border border-primary/30 shadow-lg',
|
||||||
|
'text-primary font-medium'
|
||||||
|
)}>
|
||||||
|
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
|
||||||
|
Отпустите чтобы добавить в группу
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground/60">
|
||||||
|
<p className="text-sm font-medium">Перетащите карточку сюда</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TemplateGroupProps {
|
||||||
|
group: Group | null // null для группы "Без группы"
|
||||||
|
templates: Template[]
|
||||||
|
selectionMode: boolean
|
||||||
|
selectedTemplates: Set<string>
|
||||||
|
onToggleSelect: (id: string) => void
|
||||||
|
onCopy: (templateId: string) => void
|
||||||
|
onEdit: (templateId: string) => void
|
||||||
|
onDelete: (templateId: string) => void
|
||||||
|
onEditGroup?: (groupId: string) => void
|
||||||
|
onDeleteGroup?: (groupId: string) => void
|
||||||
|
activeFilters: Record<string, string>
|
||||||
|
onCreateTemplate?: (groupId?: string) => void
|
||||||
|
isExpanded?: boolean
|
||||||
|
onToggleExpanded?: () => void
|
||||||
|
showEmptyState?: boolean // Показывать ли пустое состояние при фильтрации
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateGroup: React.FC<TemplateGroupProps> = ({
|
||||||
|
group,
|
||||||
|
templates,
|
||||||
|
selectionMode,
|
||||||
|
selectedTemplates,
|
||||||
|
onToggleSelect,
|
||||||
|
onCopy,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onEditGroup,
|
||||||
|
onDeleteGroup,
|
||||||
|
activeFilters,
|
||||||
|
onCreateTemplate,
|
||||||
|
isExpanded: externalIsExpanded,
|
||||||
|
onToggleExpanded,
|
||||||
|
showEmptyState = false,
|
||||||
|
}) => {
|
||||||
|
const [internalIsExpanded, setInternalIsExpanded] = useState(true)
|
||||||
|
|
||||||
|
// Используем внешнее состояние если передано, иначе внутреннее
|
||||||
|
const isExpanded = externalIsExpanded !== undefined ? externalIsExpanded : internalIsExpanded
|
||||||
|
|
||||||
|
// Отладка: логируем получение шаблонов в группе
|
||||||
|
console.log(`🏗️ TemplateGroup render for "${group?.name || 'Без группы'}":`, {
|
||||||
|
groupId: group?.id || 'ungrouped',
|
||||||
|
templatesCount: templates.length,
|
||||||
|
templates: templates.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
order: t.order,
|
||||||
|
group_id: t.group_id
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleExpanded = useCallback(() => {
|
||||||
|
if (onToggleExpanded) {
|
||||||
|
onToggleExpanded()
|
||||||
|
} else {
|
||||||
|
setInternalIsExpanded(prev => !prev)
|
||||||
|
}
|
||||||
|
}, [onToggleExpanded])
|
||||||
|
|
||||||
|
const handleEditGroup = useCallback(() => {
|
||||||
|
if (group && onEditGroup) {
|
||||||
|
onEditGroup(group.id)
|
||||||
|
}
|
||||||
|
}, [group, onEditGroup])
|
||||||
|
|
||||||
|
const handleDeleteGroup = useCallback(() => {
|
||||||
|
if (group && onDeleteGroup) {
|
||||||
|
onDeleteGroup(group.id)
|
||||||
|
}
|
||||||
|
}, [group, onDeleteGroup])
|
||||||
|
|
||||||
|
const handleCreateTemplate = useCallback(() => {
|
||||||
|
if (onCreateTemplate) {
|
||||||
|
onCreateTemplate(group?.id)
|
||||||
|
}
|
||||||
|
}, [group, onCreateTemplate])
|
||||||
|
|
||||||
|
// Показываем все группы, включая пустую группу "Без группы"
|
||||||
|
|
||||||
|
const groupName = group?.name || 'Без группы'
|
||||||
|
const canManageGroup = group !== null
|
||||||
|
const groupId = group?.id || 'ungrouped'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
{/* Заголовок группы */}
|
||||||
|
<div className="mb-3 flex items-center justify-between rounded-lg p-4 bg-muted/30 min-h-[60px]">
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 flex-shrink-0"
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isExpanded ? (
|
||||||
|
<FolderOpen className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<FolderClosed className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold">{groupName}</h3>
|
||||||
|
|
||||||
|
<Badge variant="secondary" className={clsx(
|
||||||
|
"text-xs ml-2",
|
||||||
|
showEmptyState && templates.length === 0 && "bg-amber-100 text-amber-800 border-amber-200"
|
||||||
|
)}>
|
||||||
|
{templates.length}
|
||||||
|
{showEmptyState && templates.length === 0 && " (отфильтровано)"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{canManageGroup && (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-48 p-1" align="end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={handleEditGroup}
|
||||||
|
>
|
||||||
|
<Edit3 className="mr-2 h-4 w-4" />
|
||||||
|
Редактировать группу
|
||||||
|
</Button>
|
||||||
|
{templates.length === 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start text-destructive hover:text-destructive"
|
||||||
|
onClick={handleDeleteGroup}
|
||||||
|
>
|
||||||
|
Удалить группу
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопка создания шаблона - outline стиль */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCreateTemplate}
|
||||||
|
className="h-8 px-3"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
Создать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Карточки шаблонов */}
|
||||||
|
{isExpanded && (
|
||||||
|
<>
|
||||||
|
{showEmptyState && templates.length === 0 ? (
|
||||||
|
<div className="py-10 text-center">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-muted-foreground/60">
|
||||||
|
<p className="text-sm font-medium">Нет результатов</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
Попробуйте изменить фильтры
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DroppableGroup
|
||||||
|
groupId={groupId}
|
||||||
|
templates={templates}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
selectedTemplates={selectedTemplates}
|
||||||
|
onToggleSelect={onToggleSelect}
|
||||||
|
onCopy={onCopy}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
activeFilters={activeFilters}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
src/entity/group/api/groupApiService.ts
Normal file
142
src/entity/group/api/groupApiService.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { Group } from '@/type/template'
|
||||||
|
|
||||||
|
// Интерфейсы для API групп
|
||||||
|
export interface GetGroupsOutput {
|
||||||
|
groups: Group[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetGroupOutput {
|
||||||
|
group: Group | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGroupInput {
|
||||||
|
name: string
|
||||||
|
order?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGroupOutput {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchGroupInput {
|
||||||
|
name?: string | null
|
||||||
|
order?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchGroupOutput extends Group {}
|
||||||
|
|
||||||
|
// API получение всех групп
|
||||||
|
export async function getGroupsApi(): Promise<GetGroupsOutput> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/groups/', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: GetGroupsOutput = await response.json()
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при получении групп:', error)
|
||||||
|
throw new Error('Ошибка при получении групп с сервера')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API получение конкретной группы
|
||||||
|
export async function getGroupApi(groupId: string): Promise<GetGroupOutput> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/groups/${groupId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: GetGroupOutput = await response.json()
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при получении группы:', error)
|
||||||
|
throw new Error('Ошибка при получении группы с сервера')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API создание группы
|
||||||
|
export async function createGroupApi(
|
||||||
|
group: CreateGroupInput
|
||||||
|
): Promise<CreateGroupOutput> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/groups/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(group),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: CreateGroupOutput = await response.json()
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при создании группы:', error)
|
||||||
|
throw new Error('Ошибка при создании группы на сервере')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API обновление группы
|
||||||
|
export async function patchGroupApi(
|
||||||
|
groupId: string,
|
||||||
|
group: PatchGroupInput
|
||||||
|
): Promise<PatchGroupOutput> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/groups/${groupId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(group),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: PatchGroupOutput = await response.json()
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при обновлении группы:', error)
|
||||||
|
throw new Error('Ошибка при обновлении группы на сервере')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API удаление группы (если потребуется)
|
||||||
|
export async function deleteGroupApi(groupId: string): Promise<{success: boolean}> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/groups/${groupId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при удалении группы:', error)
|
||||||
|
throw new Error('Ошибка при удалении группы на сервере')
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/entity/group/model/GroupContext.tsx
Normal file
137
src/entity/group/model/GroupContext.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import {
|
||||||
|
createGroupApi,
|
||||||
|
CreateGroupInput,
|
||||||
|
deleteGroupApi,
|
||||||
|
getGroupsApi,
|
||||||
|
patchGroupApi,
|
||||||
|
PatchGroupInput
|
||||||
|
} from '@/entity/group/api/groupApiService'
|
||||||
|
import { Group } from '@/type/template'
|
||||||
|
import {
|
||||||
|
QueryClient,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
import { createContext, ReactNode, useContext, useMemo } from 'react'
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
interface GroupContextType {
|
||||||
|
groups: Group[]
|
||||||
|
isLoading: boolean
|
||||||
|
error: Error | null
|
||||||
|
refetchGroups: () => void
|
||||||
|
addGroup: (data: CreateGroupInput) => Promise<Group>
|
||||||
|
updateGroup: (
|
||||||
|
groupId: string,
|
||||||
|
data: PatchGroupInput
|
||||||
|
) => Promise<Group>
|
||||||
|
deleteGroup: (groupId: string) => Promise<string>
|
||||||
|
getGroupById: (groupId: string) => Group | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupContext = createContext<GroupContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
export const GroupProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const client = useQueryClient()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: groups = [],
|
||||||
|
status: listStatus,
|
||||||
|
error: errorList,
|
||||||
|
refetch,
|
||||||
|
} = useQuery<Group[], Error>({
|
||||||
|
queryKey: ['groups'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { groups: apiList } = await getGroupsApi()
|
||||||
|
return apiList.sort((a, b) => a.order - b.order)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async (group: CreateGroupInput) => {
|
||||||
|
const { id } = await createGroupApi(group)
|
||||||
|
// Возвращаем созданную группу, получив её с сервера
|
||||||
|
const { groups: updatedGroups } = await getGroupsApi()
|
||||||
|
const newGroup = updatedGroups.find(g => g.id === id)
|
||||||
|
if (!newGroup) throw new Error('Группа не найдена после создания')
|
||||||
|
return newGroup
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
client.invalidateQueries({ queryKey: ['groups'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
groupId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
groupId: string
|
||||||
|
data: PatchGroupInput
|
||||||
|
}) => {
|
||||||
|
return await patchGroupApi(groupId, data)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
client.invalidateQueries({ queryKey: ['groups'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (groupId: string) => {
|
||||||
|
await deleteGroupApi(groupId)
|
||||||
|
return groupId
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
client.invalidateQueries({ queryKey: ['groups'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const getGroupById = (groupId: string) =>
|
||||||
|
groups.find(group => group.id === groupId)
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
groups,
|
||||||
|
isLoading:
|
||||||
|
listStatus === 'pending' ||
|
||||||
|
createMutation.status === 'pending' ||
|
||||||
|
updateMutation.status === 'pending' ||
|
||||||
|
deleteMutation.status === 'pending',
|
||||||
|
error:
|
||||||
|
(errorList as Error) ||
|
||||||
|
(createMutation.error as Error) ||
|
||||||
|
(updateMutation.error as Error) ||
|
||||||
|
(deleteMutation.error as Error) ||
|
||||||
|
null,
|
||||||
|
refetchGroups: refetch,
|
||||||
|
addGroup: createMutation.mutateAsync,
|
||||||
|
updateGroup: async (groupId: string, data: PatchGroupInput) =>
|
||||||
|
updateMutation.mutateAsync({ groupId, data }),
|
||||||
|
deleteGroup: deleteMutation.mutateAsync,
|
||||||
|
getGroupById,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
groups,
|
||||||
|
listStatus,
|
||||||
|
errorList,
|
||||||
|
createMutation,
|
||||||
|
updateMutation,
|
||||||
|
deleteMutation,
|
||||||
|
refetch,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return <GroupContext.Provider value={value}>{children}</GroupContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGroupContext = (): GroupContextType => {
|
||||||
|
const context = useContext(GroupContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useGroupContext must be used within a GroupProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ export interface CreateTemplateInput {
|
|||||||
attribute_id: string
|
attribute_id: string
|
||||||
value: string | null
|
value: string | null
|
||||||
}>
|
}>
|
||||||
|
group_id?: string | null
|
||||||
|
order?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateTemplateOutput {
|
export interface CreateTemplateOutput {
|
||||||
@@ -32,6 +34,8 @@ export interface PatchTemplateInput {
|
|||||||
attribute_id: string
|
attribute_id: string
|
||||||
value: string | null
|
value: string | null
|
||||||
}> | null
|
}> | null
|
||||||
|
group_id?: string | null | "ungroup"
|
||||||
|
order?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PatchTemplateOutput {
|
export interface PatchTemplateOutput {
|
||||||
@@ -43,6 +47,8 @@ export interface ApiTemplate {
|
|||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
elements: Record<string, any>
|
elements: Record<string, any>
|
||||||
|
group_id?: string | null
|
||||||
|
order?: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
deleted_at?: string | null
|
deleted_at?: string | null
|
||||||
@@ -62,9 +68,9 @@ export interface ApiTemplateWithAttributes extends ApiTemplate {
|
|||||||
// Импортируем типы для преобразования
|
// Импортируем типы для преобразования
|
||||||
import { getDefaultLayoutSettings, migrateTemplateElement } from '@/lib/utils'
|
import { getDefaultLayoutSettings, migrateTemplateElement } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
Template,
|
Template,
|
||||||
TemplateAttributeDetail,
|
TemplateAttributeDetail,
|
||||||
TemplateElement,
|
TemplateElement,
|
||||||
} from '@/type/template'
|
} from '@/type/template'
|
||||||
|
|
||||||
// Утилитарные функции для преобразования данных
|
// Утилитарные функции для преобразования данных
|
||||||
@@ -74,7 +80,7 @@ export function apiTemplateToTemplate(apiTemplate: ApiTemplate): Template {
|
|||||||
// Преобразуем elements из Record<string, any> в TemplateElement[]
|
// Преобразуем elements из Record<string, any> в TemplateElement[]
|
||||||
const elements: TemplateElement[] = Object.values(apiTemplate.elements || {})
|
const elements: TemplateElement[] = Object.values(apiTemplate.elements || {})
|
||||||
.map(el => migrateTemplateElement(el))
|
.map(el => migrateTemplateElement(el))
|
||||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: apiTemplate.id,
|
id: apiTemplate.id,
|
||||||
@@ -82,6 +88,8 @@ export function apiTemplateToTemplate(apiTemplate: ApiTemplate): Template {
|
|||||||
description: apiTemplate.description || undefined,
|
description: apiTemplate.description || undefined,
|
||||||
elements,
|
elements,
|
||||||
layoutSettings: getDefaultLayoutSettings(),
|
layoutSettings: getDefaultLayoutSettings(),
|
||||||
|
group_id: apiTemplate.group_id,
|
||||||
|
order: apiTemplate.order, // ВАЖНО: добавляем order!
|
||||||
createdAt: new Date(apiTemplate.created_at),
|
createdAt: new Date(apiTemplate.created_at),
|
||||||
updatedAt: new Date(apiTemplate.updated_at),
|
updatedAt: new Date(apiTemplate.updated_at),
|
||||||
}
|
}
|
||||||
@@ -120,6 +128,8 @@ export function templateToCreateInput(
|
|||||||
name: template.name,
|
name: template.name,
|
||||||
description: template.description,
|
description: template.description,
|
||||||
elements,
|
elements,
|
||||||
|
group_id: template.group_id,
|
||||||
|
order: template.order,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +144,8 @@ export function templateToPatchInput(template: Template): PatchTemplateInput {
|
|||||||
name: template.name,
|
name: template.name,
|
||||||
description: template.description,
|
description: template.description,
|
||||||
elements,
|
elements,
|
||||||
|
group_id: template.group_id,
|
||||||
|
order: template.order,
|
||||||
// attributes будут добавлены отдельно в TemplateContext при необходимости
|
// attributes будут добавлены отдельно в TemplateContext при необходимости
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,6 +178,8 @@ export async function createTemplateApi(
|
|||||||
// API получение всех шаблонов
|
// API получение всех шаблонов
|
||||||
export async function getTemplatesApi(): Promise<GetTemplatesOutput> {
|
export async function getTemplatesApi(): Promise<GetTemplatesOutput> {
|
||||||
try {
|
try {
|
||||||
|
console.log('🌐 GET API call to /api/templates/')
|
||||||
|
|
||||||
const response = await fetch('/api/templates/', {
|
const response = await fetch('/api/templates/', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -173,11 +187,23 @@ export async function getTemplatesApi(): Promise<GetTemplatesOutput> {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log(`🌐 GET API response status: ${response.status}`)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`)
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: GetTemplatesOutput = await response.json()
|
const result: GetTemplatesOutput = await response.json()
|
||||||
|
console.log('🌐 GET API raw response:', result)
|
||||||
|
|
||||||
|
// Дополнительная отладка order полей
|
||||||
|
console.log('🌐 Templates with order info:', result.templates.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
order: t.order,
|
||||||
|
orderType: typeof t.order
|
||||||
|
})))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при получении шаблонов:', error)
|
console.error('Ошибка при получении шаблонов:', error)
|
||||||
@@ -239,6 +265,8 @@ export async function patchTemplateApi(
|
|||||||
template: PatchTemplateInput
|
template: PatchTemplateInput
|
||||||
): Promise<PatchTemplateOutput> {
|
): Promise<PatchTemplateOutput> {
|
||||||
try {
|
try {
|
||||||
|
console.log(`🌐 PATCH API call to /api/templates/${templateId}`, template)
|
||||||
|
|
||||||
const response = await fetch(`/api/templates/${templateId}`, {
|
const response = await fetch(`/api/templates/${templateId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -247,11 +275,16 @@ export async function patchTemplateApi(
|
|||||||
body: JSON.stringify(template),
|
body: JSON.stringify(template),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log(`🌐 PATCH API response status: ${response.status}`)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
console.error(`🌐 PATCH API error response:`, errorText)
|
||||||
throw new Error(`HTTP error! status: ${response.status}`)
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: PatchTemplateOutput = await response.json()
|
const result: PatchTemplateOutput = await response.json()
|
||||||
|
console.log(`🌐 PATCH API success result:`, result)
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при обновлении шаблона:', error)
|
console.error('Ошибка при обновлении шаблона:', error)
|
||||||
@@ -276,6 +309,8 @@ export interface CopyTemplateInput {
|
|||||||
attribute_id: string
|
attribute_id: string
|
||||||
value: string | null
|
value: string | null
|
||||||
}> | null
|
}> | null
|
||||||
|
group_id?: string | null
|
||||||
|
copy_first_sheet_empty?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CopyTemplateOutput {
|
export interface CopyTemplateOutput {
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import {
|
import {
|
||||||
apiTemplateToTemplate,
|
apiTemplateToTemplate,
|
||||||
apiTemplateWithAttributesToTemplate,
|
apiTemplateWithAttributesToTemplate,
|
||||||
copyTemplateApi,
|
copyTemplateApi,
|
||||||
CopyTemplateInput,
|
CopyTemplateInput,
|
||||||
createTemplateApi,
|
createTemplateApi,
|
||||||
deleteTemplateApi,
|
deleteTemplateApi,
|
||||||
getTemplateApi,
|
getTemplateApi,
|
||||||
getTemplatesApi,
|
getTemplatesApi,
|
||||||
patchTemplateApi,
|
patchTemplateApi,
|
||||||
templateToCreateInput,
|
templateToCreateInput,
|
||||||
templateToPatchInput,
|
templateToPatchInput,
|
||||||
} from '@/entity/template/api/templateApiService'
|
} from '@/entity/template/api/templateApiService'
|
||||||
import { Template } from '@/type/template'
|
import { Template } from '@/type/template'
|
||||||
import {
|
import {
|
||||||
QueryClient,
|
QueryClient,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from '@tanstack/react-query'
|
} from '@tanstack/react-query'
|
||||||
import React, { createContext, ReactNode, useContext, useMemo } from 'react'
|
import React, { createContext, ReactNode, useContext, useMemo } from 'react'
|
||||||
|
|
||||||
@@ -40,8 +40,12 @@ interface TemplateContextType {
|
|||||||
templateId: string,
|
templateId: string,
|
||||||
newName: string,
|
newName: string,
|
||||||
newDescription?: string,
|
newDescription?: string,
|
||||||
attributes?: Array<{ attributeId: string; value: string | null }>
|
attributes?: Array<{ attributeId: string; value: string | null }>,
|
||||||
|
groupId?: string | null,
|
||||||
|
copyFirstSheetEmpty?: boolean
|
||||||
) => Promise<Template>
|
) => Promise<Template>
|
||||||
|
updateTemplateOrder: (templateId: string, newOrder: number) => Promise<void>
|
||||||
|
moveTemplateToGroup: (templateId: string, groupId: string | null) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const TemplateContext = createContext<TemplateContextType | undefined>(
|
const TemplateContext = createContext<TemplateContextType | undefined>(
|
||||||
@@ -61,8 +65,60 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
} = useQuery<Template[], Error>({
|
} = useQuery<Template[], Error>({
|
||||||
queryKey: ['templates'],
|
queryKey: ['templates'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
console.log('🔄 RELOADING TEMPLATES FROM API')
|
||||||
const { templates: apiList } = await getTemplatesApi()
|
const { templates: apiList } = await getTemplatesApi()
|
||||||
return apiList.map(apiTemplateWithAttributesToTemplate)
|
console.log('📡 Raw API templates:', apiList.map(t => ({ id: t.id, name: t.name, order: t.order, group_id: t.group_id })))
|
||||||
|
|
||||||
|
const processedTemplates = apiList.map(apiTemplateWithAttributesToTemplate)
|
||||||
|
console.log('🔧 Processed templates:', processedTemplates.map(t => ({ id: t.id, name: t.name, order: t.order, group_id: t.group_id })))
|
||||||
|
|
||||||
|
// Проверяем если есть шаблоны без order (null, undefined или 0) и автоматически их исправляем
|
||||||
|
const templatesWithoutOrder = processedTemplates.filter(t => t.order == null || t.order === 0)
|
||||||
|
if (templatesWithoutOrder.length > 0) {
|
||||||
|
console.log('Found templates without valid order, fixing:', templatesWithoutOrder.map(t => ({ id: t.id, name: t.name, order: t.order })))
|
||||||
|
|
||||||
|
// Группируем по группам и задаем order
|
||||||
|
const templatesByGroup: Record<string, Template[]> = {}
|
||||||
|
processedTemplates.forEach(template => {
|
||||||
|
const groupKey = template.group_id || 'ungrouped'
|
||||||
|
if (!templatesByGroup[groupKey]) templatesByGroup[groupKey] = []
|
||||||
|
templatesByGroup[groupKey].push(template)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Для каждой группы обновляем шаблоны без order
|
||||||
|
for (const [groupKey, groupTemplates] of Object.entries(templatesByGroup)) {
|
||||||
|
const templatesWithOrder = groupTemplates.filter(t => t.order != null && t.order > 0)
|
||||||
|
const templatesWithoutOrderInGroup = groupTemplates.filter(t => t.order == null || t.order === 0)
|
||||||
|
|
||||||
|
if (templatesWithoutOrderInGroup.length > 0) {
|
||||||
|
const maxOrder = Math.max(0, ...templatesWithOrder.map(t => t.order ?? 0))
|
||||||
|
|
||||||
|
// Обновляем каждый шаблон без order
|
||||||
|
for (let i = 0; i < templatesWithoutOrderInGroup.length; i++) {
|
||||||
|
const template = templatesWithoutOrderInGroup[i]
|
||||||
|
const oldOrder = template.order
|
||||||
|
const newOrder = maxOrder + (i + 1)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Обновляем в базе данных
|
||||||
|
await patchTemplateApi(template.id, {
|
||||||
|
name: template.name,
|
||||||
|
order: newOrder
|
||||||
|
})
|
||||||
|
|
||||||
|
// Обновляем локально
|
||||||
|
template.order = newOrder
|
||||||
|
console.log(`Updated template ${template.id} order from ${oldOrder} to ${newOrder}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to update order for template ${template.id}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ FINAL TEMPLATES RESULT:', processedTemplates.map(t => ({ id: t.id, name: t.name, order: t.order, group_id: t.group_id })))
|
||||||
|
return processedTemplates
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -140,11 +196,15 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
newName,
|
newName,
|
||||||
newDescription,
|
newDescription,
|
||||||
attributes,
|
attributes,
|
||||||
|
groupId,
|
||||||
|
copyFirstSheetEmpty,
|
||||||
}: {
|
}: {
|
||||||
templateId: string
|
templateId: string
|
||||||
newName: string
|
newName: string
|
||||||
newDescription?: string
|
newDescription?: string
|
||||||
attributes?: Array<{ attributeId: string; value: string | null }>
|
attributes?: Array<{ attributeId: string; value: string | null }>
|
||||||
|
groupId?: string | null
|
||||||
|
copyFirstSheetEmpty?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const input: CopyTemplateInput = {
|
const input: CopyTemplateInput = {
|
||||||
template_id: templateId,
|
template_id: templateId,
|
||||||
@@ -155,6 +215,8 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
attribute_id: attr.attributeId,
|
attribute_id: attr.attributeId,
|
||||||
value: attr.value,
|
value: attr.value,
|
||||||
})) || null,
|
})) || null,
|
||||||
|
group_id: groupId,
|
||||||
|
copy_first_sheet_empty: copyFirstSheetEmpty,
|
||||||
}
|
}
|
||||||
const { new_template_id } = await copyTemplateApi(input)
|
const { new_template_id } = await copyTemplateApi(input)
|
||||||
const { template: apiTpl } = await getTemplateApi(new_template_id)
|
const { template: apiTpl } = await getTemplateApi(new_template_id)
|
||||||
@@ -166,6 +228,70 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Мутация для обновления порядка шаблона
|
||||||
|
const updateOrderMutation = useMutation({
|
||||||
|
mutationFn: async ({ templateId, newOrder }: { templateId: string; newOrder: number }) => {
|
||||||
|
const template = templates.find(t => t.id === templateId)
|
||||||
|
if (!template) throw new Error('Template not found')
|
||||||
|
|
||||||
|
console.log('🔄 Updating template order:', { templateId, newOrder, template: { id: template.id, name: template.name, currentOrder: template.order } })
|
||||||
|
|
||||||
|
// Обновляем только порядок, но включаем обязательные поля
|
||||||
|
const patchInput: PatchTemplateInput = {
|
||||||
|
name: template.name, // Включаем name как обязательное поле
|
||||||
|
order: newOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📤 Sending to API:', patchInput)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await patchTemplateApi(templateId, patchInput)
|
||||||
|
console.log('Order update successful:', result)
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Order update failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
client.invalidateQueries({ queryKey: ['templates'] })
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Update order mutation error:', error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Мутация для перемещения шаблона в другую группу
|
||||||
|
const moveToGroupMutation = useMutation({
|
||||||
|
mutationFn: async ({ templateId, groupId }: { templateId: string; groupId: string | null }) => {
|
||||||
|
const template = templates.find(t => t.id === templateId)
|
||||||
|
if (!template) throw new Error('Template not found')
|
||||||
|
|
||||||
|
console.log('Moving template to group:', { templateId, groupId, template })
|
||||||
|
|
||||||
|
// Если groupId равен null, отправляем "ungroup" для удаления из группы
|
||||||
|
const patchInput: PatchTemplateInput = {
|
||||||
|
name: template.name, // Включаем name как обязательное поле
|
||||||
|
group_id: groupId === null ? "ungroup" : groupId
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await patchTemplateApi(templateId, patchInput)
|
||||||
|
console.log('Group move successful:', result)
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Group move failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
client.invalidateQueries({ queryKey: ['templates'] })
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Move to group mutation error:', error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
templates,
|
templates,
|
||||||
@@ -174,13 +300,17 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
createMutation.status === 'pending' ||
|
createMutation.status === 'pending' ||
|
||||||
updateMutation.status === 'pending' ||
|
updateMutation.status === 'pending' ||
|
||||||
deleteMutation.status === 'pending' ||
|
deleteMutation.status === 'pending' ||
|
||||||
copyMutation.status === 'pending',
|
copyMutation.status === 'pending' ||
|
||||||
|
updateOrderMutation.status === 'pending' ||
|
||||||
|
moveToGroupMutation.status === 'pending',
|
||||||
error:
|
error:
|
||||||
(errorList as Error) ||
|
(errorList as Error) ||
|
||||||
(createMutation.error as Error) ||
|
(createMutation.error as Error) ||
|
||||||
(updateMutation.error as Error) ||
|
(updateMutation.error as Error) ||
|
||||||
(deleteMutation.error as Error) ||
|
(deleteMutation.error as Error) ||
|
||||||
(copyMutation.error as Error) ||
|
(copyMutation.error as Error) ||
|
||||||
|
(updateOrderMutation.error as Error) ||
|
||||||
|
(moveToGroupMutation.error as Error) ||
|
||||||
null,
|
null,
|
||||||
refetchTemplates: refetch,
|
refetchTemplates: refetch,
|
||||||
addTemplate: async (
|
addTemplate: async (
|
||||||
@@ -204,14 +334,22 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
templateId: string,
|
templateId: string,
|
||||||
newName: string,
|
newName: string,
|
||||||
newDescription?: string,
|
newDescription?: string,
|
||||||
attributes?: Array<{ attributeId: string; value: string | null }>
|
attributes?: Array<{ attributeId: string; value: string | null }>,
|
||||||
|
groupId?: string | null,
|
||||||
|
copyFirstSheetEmpty?: boolean
|
||||||
) =>
|
) =>
|
||||||
copyMutation.mutateAsync({
|
copyMutation.mutateAsync({
|
||||||
templateId,
|
templateId,
|
||||||
newName,
|
newName,
|
||||||
newDescription,
|
newDescription,
|
||||||
attributes,
|
attributes,
|
||||||
|
groupId,
|
||||||
|
copyFirstSheetEmpty,
|
||||||
}),
|
}),
|
||||||
|
updateTemplateOrder: async (templateId: string, newOrder: number) =>
|
||||||
|
updateOrderMutation.mutateAsync({ templateId, newOrder }),
|
||||||
|
moveTemplateToGroup: async (templateId: string, groupId: string | null) =>
|
||||||
|
moveToGroupMutation.mutateAsync({ templateId, groupId }),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
templates,
|
templates,
|
||||||
@@ -225,11 +363,17 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
deleteMutation.error,
|
deleteMutation.error,
|
||||||
copyMutation.status,
|
copyMutation.status,
|
||||||
copyMutation.error,
|
copyMutation.error,
|
||||||
|
updateOrderMutation.status,
|
||||||
|
updateOrderMutation.error,
|
||||||
|
moveToGroupMutation.status,
|
||||||
|
moveToGroupMutation.error,
|
||||||
refetch,
|
refetch,
|
||||||
createMutation.mutateAsync,
|
createMutation.mutateAsync,
|
||||||
updateMutation.mutateAsync,
|
updateMutation.mutateAsync,
|
||||||
deleteMutation.mutateAsync,
|
deleteMutation.mutateAsync,
|
||||||
copyMutation.mutateAsync,
|
copyMutation.mutateAsync,
|
||||||
|
updateOrderMutation.mutateAsync,
|
||||||
|
moveToGroupMutation.mutateAsync,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
381
src/hook/useTemplateOrderState.ts
Normal file
381
src/hook/useTemplateOrderState.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import { patchTemplateApi } from '@/entity/template/api/templateApiService'
|
||||||
|
import { generateInitialOrder, generateOrder } from '@/lib/template-order'
|
||||||
|
import { Group, Template } from '@/type/template'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
interface TemplateOrderChange {
|
||||||
|
templateId: string
|
||||||
|
newOrder: number
|
||||||
|
originalOrder: number
|
||||||
|
newGroupId?: string | null
|
||||||
|
originalGroupId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const EXPANDED_GROUPS_KEY = 'template-groups-expanded-state'
|
||||||
|
|
||||||
|
// Загрузить состояние сворачивания из localStorage
|
||||||
|
function loadExpandedStatesFromStorage(): Map<string, boolean> {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(EXPANDED_GROUPS_KEY)
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored)
|
||||||
|
return new Map(Object.entries(parsed))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Ошибка при загрузке состояния групп из localStorage:', error)
|
||||||
|
}
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранить состояние сворачивания в localStorage
|
||||||
|
function saveExpandedStatesToStorage(states: Map<string, boolean>) {
|
||||||
|
try {
|
||||||
|
const obj = Object.fromEntries(states)
|
||||||
|
localStorage.setItem(EXPANDED_GROUPS_KEY, JSON.stringify(obj))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Ошибка при сохранении состояния групп в localStorage:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTemplateOrderState(templates: Template[], groups: Group[] = []) {
|
||||||
|
const [orderChanges, setOrderChanges] = useState<Map<string, TemplateOrderChange>>(new Map())
|
||||||
|
const [groupExpandedStates, setGroupExpandedStates] = useState<Map<string, boolean>>(() =>
|
||||||
|
loadExpandedStatesFromStorage()
|
||||||
|
)
|
||||||
|
const [lastSaveError, setLastSaveError] = useState<string | null>(null)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// Очистить состояния для несуществующих групп
|
||||||
|
useEffect(() => {
|
||||||
|
// Ждем пока группы загрузятся перед очисткой
|
||||||
|
if (groups.length === 0) return
|
||||||
|
|
||||||
|
const existingGroupIds = new Set(groups.map(g => g.id))
|
||||||
|
// Добавляем 'ungrouped' как валидный ключ
|
||||||
|
existingGroupIds.add('ungrouped')
|
||||||
|
|
||||||
|
const currentStates = new Map(groupExpandedStates)
|
||||||
|
let hasChanges = false
|
||||||
|
|
||||||
|
// Удаляем состояния для групп, которых больше нет
|
||||||
|
for (const groupId of currentStates.keys()) {
|
||||||
|
if (!existingGroupIds.has(groupId)) {
|
||||||
|
currentStates.delete(groupId)
|
||||||
|
hasChanges = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
setGroupExpandedStates(currentStates)
|
||||||
|
saveExpandedStatesToStorage(currentStates)
|
||||||
|
}
|
||||||
|
}, [groups, groupExpandedStates])
|
||||||
|
|
||||||
|
// Применить изменения порядка локально (для UI)
|
||||||
|
const applyOrderChange = useCallback((templateId: string, newOrder: number, newGroupId?: string | null) => {
|
||||||
|
const template = templates.find(t => t.id === templateId)
|
||||||
|
if (!template) {
|
||||||
|
console.log('❌ Template not found for applyOrderChange:', templateId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔧 applyOrderChange called:', {
|
||||||
|
templateId,
|
||||||
|
templateName: template.name,
|
||||||
|
currentOrder: template.order,
|
||||||
|
newOrder,
|
||||||
|
currentGroup: template.group_id,
|
||||||
|
newGroupId
|
||||||
|
})
|
||||||
|
|
||||||
|
setOrderChanges(prev => {
|
||||||
|
const newChanges = new Map(prev)
|
||||||
|
const originalOrder = template.order ?? 0
|
||||||
|
const originalGroupId = template.group_id
|
||||||
|
|
||||||
|
console.log('📊 Order change details:', {
|
||||||
|
templateId,
|
||||||
|
originalOrder,
|
||||||
|
newOrder,
|
||||||
|
originalGroupId,
|
||||||
|
newGroupId,
|
||||||
|
orderChanged: newOrder !== originalOrder,
|
||||||
|
groupChanged: newGroupId !== originalGroupId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Если ничего не изменилось, убираем изменение
|
||||||
|
if (newOrder === originalOrder && newGroupId === originalGroupId) {
|
||||||
|
console.log('🔄 No changes detected, removing from changes map')
|
||||||
|
newChanges.delete(templateId)
|
||||||
|
} else {
|
||||||
|
const change = {
|
||||||
|
templateId,
|
||||||
|
newOrder,
|
||||||
|
originalOrder,
|
||||||
|
newGroupId,
|
||||||
|
originalGroupId,
|
||||||
|
}
|
||||||
|
console.log('💾 Storing order change:', change)
|
||||||
|
newChanges.set(templateId, change)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📈 Current changes map size:', newChanges.size)
|
||||||
|
console.log('📋 All current changes:', Array.from(newChanges.values()))
|
||||||
|
|
||||||
|
return newChanges
|
||||||
|
})
|
||||||
|
}, [templates])
|
||||||
|
|
||||||
|
// Получить актуальные шаблоны с примененными изменениями
|
||||||
|
const getTemplatesWithChanges = useCallback(() => {
|
||||||
|
const result = templates.map(template => {
|
||||||
|
const change = orderChanges.get(template.id)
|
||||||
|
if (change) {
|
||||||
|
const modifiedTemplate = {
|
||||||
|
...template,
|
||||||
|
order: change.newOrder,
|
||||||
|
group_id: change.newGroupId !== undefined ? change.newGroupId : template.group_id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логируем только если есть изменения
|
||||||
|
console.log('🔄 Template with applied changes:', {
|
||||||
|
id: template.id,
|
||||||
|
name: template.name,
|
||||||
|
originalOrder: template.order,
|
||||||
|
newOrder: modifiedTemplate.order,
|
||||||
|
originalGroup: template.group_id,
|
||||||
|
newGroup: modifiedTemplate.group_id
|
||||||
|
})
|
||||||
|
|
||||||
|
return modifiedTemplate
|
||||||
|
}
|
||||||
|
return template
|
||||||
|
}).sort((a, b) => {
|
||||||
|
// Правильная обработка null/undefined порядков
|
||||||
|
const orderA = a.order ?? Number.MAX_SAFE_INTEGER
|
||||||
|
const orderB = b.order ?? Number.MAX_SAFE_INTEGER
|
||||||
|
return orderA - orderB
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, [templates, orderChanges])
|
||||||
|
|
||||||
|
// Переключить состояние развертывания группы
|
||||||
|
const toggleGroupExpanded = useCallback((groupId: string | null) => {
|
||||||
|
// Используем специальный ключ для группы "Без группы"
|
||||||
|
const storageKey = groupId || 'ungrouped'
|
||||||
|
|
||||||
|
setGroupExpandedStates(prev => {
|
||||||
|
const newStates = new Map(prev)
|
||||||
|
const currentState = newStates.get(storageKey) ?? true // По умолчанию развернуто
|
||||||
|
const newState = !currentState
|
||||||
|
newStates.set(storageKey, newState)
|
||||||
|
|
||||||
|
// Сохраняем в localStorage
|
||||||
|
saveExpandedStatesToStorage(newStates)
|
||||||
|
|
||||||
|
return newStates
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Получить состояние развертывания группы
|
||||||
|
const getGroupExpanded = useCallback((groupId: string | null) => {
|
||||||
|
// Используем тот же ключ что и в toggleGroupExpanded
|
||||||
|
const storageKey = groupId || 'ungrouped'
|
||||||
|
return groupExpandedStates.get(storageKey) ?? true // По умолчанию развернуто
|
||||||
|
}, [groupExpandedStates])
|
||||||
|
|
||||||
|
// Универсальная обработка drag & drop
|
||||||
|
const handleDragEnd = useCallback((activeId: string, overId: string) => {
|
||||||
|
if (activeId === overId) return
|
||||||
|
|
||||||
|
const templatesWithChanges = getTemplatesWithChanges()
|
||||||
|
const activeTemplate = templatesWithChanges.find(t => t.id === activeId)
|
||||||
|
|
||||||
|
if (!activeTemplate) return
|
||||||
|
|
||||||
|
// Проверяем на специальные drop зоны для групп
|
||||||
|
if (overId.startsWith('group-drop-')) {
|
||||||
|
// Перетаскивание в группу (в конец)
|
||||||
|
const targetGroupId = overId === 'group-drop-ungrouped' ? null : overId.replace('group-drop-', '')
|
||||||
|
const targetGroupTemplates = templatesWithChanges.filter(t =>
|
||||||
|
(t.group_id || null) === (targetGroupId || null)
|
||||||
|
)
|
||||||
|
const newOrder = generateInitialOrder(targetGroupTemplates, targetGroupId)
|
||||||
|
applyOrderChange(activeId, newOrder, targetGroupId)
|
||||||
|
} else {
|
||||||
|
// Перетаскивание между шаблонами
|
||||||
|
const overTemplate = templatesWithChanges.find(t => t.id === overId)
|
||||||
|
if (!overTemplate) return
|
||||||
|
|
||||||
|
const targetGroupId = overTemplate.group_id
|
||||||
|
const groupTemplates = templatesWithChanges.filter(t =>
|
||||||
|
(t.group_id || null) === (targetGroupId || null)
|
||||||
|
)
|
||||||
|
|
||||||
|
const newOrder = generateOrder(groupTemplates, activeId, overId)
|
||||||
|
applyOrderChange(activeId, newOrder, targetGroupId)
|
||||||
|
}
|
||||||
|
}, [getTemplatesWithChanges, applyOrderChange])
|
||||||
|
|
||||||
|
// Переместить шаблон в группу
|
||||||
|
const moveTemplateToGroup = useCallback((templateId: string, targetGroupId: string | null) => {
|
||||||
|
const template = templates.find(t => t.id === templateId)
|
||||||
|
if (!template) return
|
||||||
|
|
||||||
|
// Генерируем новый порядок для конца целевой группы
|
||||||
|
const templatesWithChanges = getTemplatesWithChanges()
|
||||||
|
const newOrder = generateInitialOrder(templatesWithChanges, targetGroupId)
|
||||||
|
|
||||||
|
applyOrderChange(templateId, newOrder, targetGroupId)
|
||||||
|
}, [templates, getTemplatesWithChanges, applyOrderChange])
|
||||||
|
|
||||||
|
// Сохранить все изменения
|
||||||
|
const saveOrderChanges = useCallback(async (
|
||||||
|
updateTemplateOrder: (templateId: string, newOrder: number) => Promise<void>,
|
||||||
|
moveTemplateToGroupFn: (templateId: string, groupId: string | null) => Promise<void>
|
||||||
|
) => {
|
||||||
|
if (orderChanges.size === 0) return
|
||||||
|
|
||||||
|
console.log('💾 SAVING ORDER CHANGES START')
|
||||||
|
console.log('📋 Changes to save:', Array.from(orderChanges.values()).map(change => ({
|
||||||
|
templateId: change.templateId,
|
||||||
|
oldOrder: change.originalOrder,
|
||||||
|
newOrder: change.newOrder,
|
||||||
|
oldGroup: change.originalGroupId,
|
||||||
|
newGroup: change.newGroupId
|
||||||
|
})))
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
setLastSaveError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Группируем изменения по типу
|
||||||
|
const orderOnlyChanges: TemplateOrderChange[] = []
|
||||||
|
const groupMoveChanges: TemplateOrderChange[] = []
|
||||||
|
const combinedChanges: TemplateOrderChange[] = []
|
||||||
|
|
||||||
|
Array.from(orderChanges.values()).forEach(change => {
|
||||||
|
if (change.newGroupId !== change.originalGroupId) {
|
||||||
|
if (change.newOrder !== change.originalOrder) {
|
||||||
|
// Изменились и группа, и порядок
|
||||||
|
combinedChanges.push(change)
|
||||||
|
} else {
|
||||||
|
// Изменилась только группа
|
||||||
|
groupMoveChanges.push(change)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Изменился только порядок
|
||||||
|
orderOnlyChanges.push(change)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Сначала перемещаем между группами (только изменения группы)
|
||||||
|
if (groupMoveChanges.length > 0) {
|
||||||
|
const groupMovePromises = groupMoveChanges.map(change =>
|
||||||
|
moveTemplateToGroupFn(change.templateId, change.newGroupId || null)
|
||||||
|
)
|
||||||
|
await Promise.all(groupMovePromises)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Комбинированные изменения (группа + порядок) - отправляем один запрос
|
||||||
|
if (combinedChanges.length > 0) {
|
||||||
|
// Создаем новую функцию для комбинированного обновления
|
||||||
|
const combinedPromises = combinedChanges.map(async (change) => {
|
||||||
|
const template = templates.find(t => t.id === change.templateId)
|
||||||
|
if (!template) throw new Error('Template not found')
|
||||||
|
|
||||||
|
console.log('🔧 Combined update (group + order):', {
|
||||||
|
templateId: change.templateId,
|
||||||
|
newGroupId: change.newGroupId,
|
||||||
|
newOrder: change.newOrder
|
||||||
|
})
|
||||||
|
|
||||||
|
// Отправляем один запрос с обновлением и группы, и порядка
|
||||||
|
const patchInput = {
|
||||||
|
name: template.name,
|
||||||
|
group_id: change.newGroupId === null ? "ungroup" : change.newGroupId,
|
||||||
|
order: change.newOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем внутренний API вместо двух отдельных вызовов
|
||||||
|
const result = await patchTemplateApi(change.templateId, patchInput)
|
||||||
|
console.log('Combined update successful:', result)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
await Promise.all(combinedPromises)
|
||||||
|
|
||||||
|
// Инвалидируем кэш после комбинированных обновлений
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['templates'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем изменения порядка
|
||||||
|
if (orderOnlyChanges.length > 0) {
|
||||||
|
console.log('Saving order changes:', orderOnlyChanges)
|
||||||
|
const orderPromises = orderOnlyChanges.map(async (change) => {
|
||||||
|
try {
|
||||||
|
await updateTemplateOrder(change.templateId, change.newOrder)
|
||||||
|
console.log(`Successfully updated order for template ${change.templateId}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to update order for template ${change.templateId}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all(orderPromises)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ SAVE ORDER CHANGES SUCCESS - all changes applied')
|
||||||
|
|
||||||
|
// Очищаем все сохраненные изменения - они уже применены на сервере
|
||||||
|
setOrderChanges(new Map())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при сохранении изменений:', error)
|
||||||
|
setLastSaveError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Неизвестная ошибка при сохранении изменений'
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}, [orderChanges, queryClient])
|
||||||
|
|
||||||
|
// Отменить все изменения
|
||||||
|
const resetOrderChanges = useCallback(() => {
|
||||||
|
setOrderChanges(new Map())
|
||||||
|
setLastSaveError(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Очистить ошибку
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setLastSaveError(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Сбросить все состояния групп (полезно для отладки)
|
||||||
|
const resetGroupStates = useCallback(() => {
|
||||||
|
setGroupExpandedStates(new Map())
|
||||||
|
saveExpandedStatesToStorage(new Map())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderChanges,
|
||||||
|
hasUnsavedChanges: orderChanges.size > 0,
|
||||||
|
changedCount: orderChanges.size,
|
||||||
|
lastSaveError,
|
||||||
|
isSaving,
|
||||||
|
getTemplatesWithChanges,
|
||||||
|
applyOrderChange,
|
||||||
|
handleDragEnd,
|
||||||
|
moveTemplateToGroup,
|
||||||
|
toggleGroupExpanded,
|
||||||
|
getGroupExpanded,
|
||||||
|
saveOrderChanges,
|
||||||
|
resetOrderChanges,
|
||||||
|
clearError,
|
||||||
|
resetGroupStates,
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/lib/group-storage.md
Normal file
37
src/lib/group-storage.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Система сохранения состояния групп
|
||||||
|
|
||||||
|
## Что сохраняется в localStorage
|
||||||
|
|
||||||
|
Состояние сворачивания/разворачивания групп сохраняется в localStorage браузера пользователя под ключом `template-groups-expanded-state`.
|
||||||
|
|
||||||
|
### Формат данных
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group-id-1": true, // развернута
|
||||||
|
"group-id-2": false, // свернута
|
||||||
|
"group-id-3": true // развернута
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Поведение по умолчанию
|
||||||
|
|
||||||
|
- **Новые группы** - развернуты по умолчанию
|
||||||
|
- **Группа "Без группы"** - всегда развернута (не управляется)
|
||||||
|
- **Удаленные группы** - их состояние автоматически очищается
|
||||||
|
|
||||||
|
## API изменения
|
||||||
|
|
||||||
|
- `is_visible` поле **УБРАНО** из API групп
|
||||||
|
- Сворачивание теперь **ТОЛЬКО локальное** в браузере пользователя
|
||||||
|
- Не отправляются запросы на сервер при сворачивании
|
||||||
|
|
||||||
|
## Функции управления
|
||||||
|
|
||||||
|
- `toggleGroupExpanded(groupId)` - переключить состояние группы
|
||||||
|
- `getGroupExpanded(groupId)` - получить текущее состояние
|
||||||
|
- `resetGroupStates()` - сбросить все состояния (для отладки)
|
||||||
|
|
||||||
|
## Автоочистка
|
||||||
|
|
||||||
|
Состояния для несуществующих групп автоматически удаляются при загрузке приложения.
|
||||||
76
src/lib/template-order.ts
Normal file
76
src/lib/template-order.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Template } from '@/type/template'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерирует новый порядок для шаблона при вставке между двумя существующими
|
||||||
|
* Использует дробные числа для избежания пересчета всех элементов
|
||||||
|
*/
|
||||||
|
export function generateOrder(
|
||||||
|
templates: Template[],
|
||||||
|
activeId: string,
|
||||||
|
overId: string
|
||||||
|
): number {
|
||||||
|
// Исключаем активный элемент из расчетов
|
||||||
|
const filteredTemplates = templates.filter(t => t.id !== activeId)
|
||||||
|
const sortedTemplates = [...filteredTemplates].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
|
||||||
|
const overIndex = sortedTemplates.findIndex(t => t.id === overId)
|
||||||
|
|
||||||
|
if (overIndex === -1) {
|
||||||
|
// Если целевой элемент не найден, размещаем в конце
|
||||||
|
const maxOrder = Math.max(0, ...sortedTemplates.map(t => t.order ?? 0))
|
||||||
|
return maxOrder + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const overOrder = sortedTemplates[overIndex].order ?? 0
|
||||||
|
|
||||||
|
// Вставляем перед найденным элементом
|
||||||
|
if (overIndex === 0) {
|
||||||
|
// В начало списка - делим пополам или вычитаем 1
|
||||||
|
return overOrder > 1 ? overOrder / 2 : overOrder - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevOrder = sortedTemplates[overIndex - 1]?.order ?? 0
|
||||||
|
|
||||||
|
// Вставляем между элементами - среднее арифметическое
|
||||||
|
return (prevOrder + overOrder) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Группирует шаблоны по группам и сортирует их по order
|
||||||
|
*/
|
||||||
|
export function groupTemplatesByGroup(templates: Template[]) {
|
||||||
|
const grouped = templates.reduce((acc, template) => {
|
||||||
|
const groupId = template.group_id || 'ungrouped'
|
||||||
|
if (!acc[groupId]) {
|
||||||
|
acc[groupId] = []
|
||||||
|
}
|
||||||
|
acc[groupId].push(template)
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, Template[]>)
|
||||||
|
|
||||||
|
// Сортируем шаблоны в каждой группе по order
|
||||||
|
Object.keys(grouped).forEach(groupId => {
|
||||||
|
grouped[groupId].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерирует начальный порядок для нового шаблона в группе
|
||||||
|
*/
|
||||||
|
export function generateInitialOrder(
|
||||||
|
templates: Template[],
|
||||||
|
groupId?: string | null
|
||||||
|
): number {
|
||||||
|
const groupTemplates = templates.filter(t =>
|
||||||
|
(t.group_id || null) === (groupId || null)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (groupTemplates.length === 0) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxOrder = Math.max(...groupTemplates.map(t => t.order ?? 0))
|
||||||
|
return maxOrder + 1
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
CellTarget,
|
CellTarget,
|
||||||
ElementLayout,
|
ElementLayout,
|
||||||
FormLayoutSettings,
|
FormLayoutSettings,
|
||||||
TemplateElement,
|
TemplateElement,
|
||||||
} from '@/type/template'
|
} from '@/type/template'
|
||||||
|
|
||||||
// Миграция элементов из старого формата в новый
|
// Миграция элементов из старого формата в новый
|
||||||
@@ -30,8 +30,8 @@ export function migrateTemplateElement(element: any): TemplateElement {
|
|||||||
return {
|
return {
|
||||||
...element,
|
...element,
|
||||||
targetCells,
|
targetCells,
|
||||||
order: element.order || 0,
|
order: element.order ?? 0,
|
||||||
layout: element.layout || generateDefaultLayout(element.order || 0),
|
layout: element.layout || generateDefaultLayout(element.order ?? 0),
|
||||||
} as TemplateElement
|
} as TemplateElement
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,38 @@
|
|||||||
import { CategoryFieldsSelector } from '@/component/TemplateManager/CategoryFieldsSelector'
|
import { CategoryFieldsSelector } from '@/component/TemplateManager/CategoryFieldsSelector'
|
||||||
|
import GroupManager from '@/component/TemplateManager/GroupManager'
|
||||||
|
import { OrderSaveButton } from '@/component/TemplateManager/OrderSaveButton'
|
||||||
import {
|
import {
|
||||||
TemplateFilterSidebar,
|
TemplateFilterSidebar,
|
||||||
TemplateFilters,
|
TemplateFilters,
|
||||||
} from '@/component/TemplateManager/TemplateFilterSidebar'
|
} from '@/component/TemplateManager/TemplateFilterSidebar'
|
||||||
|
import { TemplateGroup } from '@/component/TemplateManager/TemplateGroup'
|
||||||
|
import { GroupProvider, useGroupContext } from '@/entity/group/model/GroupContext'
|
||||||
import {
|
import {
|
||||||
apiTemplateWithAttributesToTemplate,
|
apiTemplateWithAttributesToTemplate,
|
||||||
getTemplateWithAttributesApi,
|
getTemplateWithAttributesApi,
|
||||||
} from '@/entity/template/api/templateApiService'
|
} from '@/entity/template/api/templateApiService'
|
||||||
import { useCategoryContext } from '@/entity/template/model/CategoryContext'
|
import { useCategoryContext } from '@/entity/template/model/CategoryContext'
|
||||||
import { useTemplateContext } from '@/entity/template/model/TemplateContext'
|
import { useTemplateContext } from '@/entity/template/model/TemplateContext'
|
||||||
import { Template, TemplateAttributeDetail } from '@/type/template'
|
import { useTemplateOrderState } from '@/hook/useTemplateOrderState'
|
||||||
|
import { generateInitialOrder, groupTemplatesByGroup } from '@/lib/template-order'
|
||||||
|
import { Group, Template, TemplateAttributeDetail } from '@/type/template'
|
||||||
import TemplateCard from '@/widget/template/ui/TemplateCard'
|
import TemplateCard from '@/widget/template/ui/TemplateCard'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverEvent,
|
||||||
|
DragOverlay,
|
||||||
|
DragStartEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
closestCenter,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
@@ -21,20 +43,28 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Trash2,
|
Trash2,
|
||||||
Type,
|
Type,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import { Button } from 'shared/ui/button'
|
import { Button } from 'shared/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from 'shared/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from 'shared/ui/card'
|
||||||
|
import { Checkbox } from 'shared/ui/checkbox'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle
|
||||||
DialogTrigger,
|
|
||||||
} from 'shared/ui/dialog'
|
} from 'shared/ui/dialog'
|
||||||
import { Input } from 'shared/ui/input'
|
import { Input } from 'shared/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from 'shared/ui/select'
|
||||||
import { Separator } from 'shared/ui/separator'
|
import { Separator } from 'shared/ui/separator'
|
||||||
import {
|
import {
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
@@ -121,6 +151,14 @@ interface TemplateFormProps {
|
|||||||
isSubmitDisabled: boolean
|
isSubmitDisabled: boolean
|
||||||
nameLabel?: string
|
nameLabel?: string
|
||||||
descriptionLabel?: string
|
descriptionLabel?: string
|
||||||
|
selectedGroupId?: string | null
|
||||||
|
onGroupChange?: (groupId: string | null) => void
|
||||||
|
showGroupSelector?: boolean
|
||||||
|
groups?: Group[]
|
||||||
|
// Опции копирования
|
||||||
|
showCopyOptions?: boolean
|
||||||
|
copyFirstSheetEmpty?: boolean
|
||||||
|
onCopyFirstSheetEmptyChange?: (value: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const TemplateForm: React.FC<TemplateFormProps> = ({
|
const TemplateForm: React.FC<TemplateFormProps> = ({
|
||||||
@@ -142,6 +180,13 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
|
|||||||
isSubmitDisabled,
|
isSubmitDisabled,
|
||||||
nameLabel = 'Название шаблона',
|
nameLabel = 'Название шаблона',
|
||||||
descriptionLabel = 'Описание',
|
descriptionLabel = 'Описание',
|
||||||
|
selectedGroupId,
|
||||||
|
onGroupChange,
|
||||||
|
showGroupSelector = false,
|
||||||
|
groups = [],
|
||||||
|
showCopyOptions = false,
|
||||||
|
copyFirstSheetEmpty = false,
|
||||||
|
onCopyFirstSheetEmptyChange,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -171,6 +216,37 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
|
|||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Селектор группы */}
|
||||||
|
{showGroupSelector && onGroupChange && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">Группа</label>
|
||||||
|
<Select
|
||||||
|
value={selectedGroupId || 'no-group'}
|
||||||
|
onValueChange={(value) => onGroupChange(value === 'no-group' ? null : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="Выберите группу" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="no-group">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-muted-foreground/40" />
|
||||||
|
<span className="text-muted-foreground">Без группы</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
{groups.map(group => (
|
||||||
|
<SelectItem key={group.id} value={group.id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||||
|
<span>{group.name}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -192,6 +268,36 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Опции копирования */}
|
||||||
|
{showCopyOptions && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Copy className="h-4 w-4 text-blue-600" />
|
||||||
|
<CardTitle className="text-sm">Опции копирования</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id="copy-first-sheet-empty"
|
||||||
|
checked={copyFirstSheetEmpty}
|
||||||
|
onCheckedChange={(checked) => onCopyFirstSheetEmptyChange?.(checked === true)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="copy-first-sheet-empty"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
|
>
|
||||||
|
Не копировать левый лист
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
При включении этой опции левый лист шаблона будет пустым в копии
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
@@ -228,14 +334,17 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TemplatesOverviewPage = () => {
|
const TemplatesPageContent = () => {
|
||||||
const {
|
const {
|
||||||
templates,
|
templates,
|
||||||
deleteTemplate,
|
deleteTemplate,
|
||||||
addTemplate,
|
addTemplate,
|
||||||
copyTemplate,
|
copyTemplate,
|
||||||
updateTemplate,
|
updateTemplate,
|
||||||
|
updateTemplateOrder,
|
||||||
|
moveTemplateToGroup,
|
||||||
} = useTemplateContext()
|
} = useTemplateContext()
|
||||||
|
const { groups, updateGroup, deleteGroup } = useGroupContext()
|
||||||
const { isLoading: attributesLoading } = useCategoryContext()
|
const { isLoading: attributesLoading } = useCategoryContext()
|
||||||
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
@@ -250,11 +359,14 @@ export const TemplatesOverviewPage = () => {
|
|||||||
const [newAttributeValues, setNewAttributeValues] = useState<
|
const [newAttributeValues, setNewAttributeValues] = useState<
|
||||||
AttributeValue[]
|
AttributeValue[]
|
||||||
>([])
|
>([])
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null) // Добавляем состояние для выбранной группы
|
||||||
const [copyName, setCopyName] = useState('')
|
const [copyName, setCopyName] = useState('')
|
||||||
const [copyDescription, setCopyDescription] = useState('')
|
const [copyDescription, setCopyDescription] = useState('')
|
||||||
const [copyAttributeValues, setCopyAttributeValues] = useState<
|
const [copyAttributeValues, setCopyAttributeValues] = useState<
|
||||||
AttributeValue[]
|
AttributeValue[]
|
||||||
>([])
|
>([])
|
||||||
|
const [copyFirstSheetEmpty, setCopyFirstSheetEmpty] = useState(false)
|
||||||
|
const [copySelectedGroupId, setCopySelectedGroupId] = useState<string | null>(null)
|
||||||
const [editName, setEditName] = useState('')
|
const [editName, setEditName] = useState('')
|
||||||
const [editDescription, setEditDescription] = useState('')
|
const [editDescription, setEditDescription] = useState('')
|
||||||
const [editAttributeValues, setEditAttributeValues] = useState<
|
const [editAttributeValues, setEditAttributeValues] = useState<
|
||||||
@@ -262,12 +374,32 @@ export const TemplatesOverviewPage = () => {
|
|||||||
>([])
|
>([])
|
||||||
const [templateAttributesLoading, setTemplateAttributesLoading] =
|
const [templateAttributesLoading, setTemplateAttributesLoading] =
|
||||||
useState(false)
|
useState(false)
|
||||||
|
const [editingGroupId, setEditingGroupId] = useState<string | null>(null)
|
||||||
const [filters, setFilters] = useState<TemplateFilters>({
|
const [filters, setFilters] = useState<TemplateFilters>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
attributes: {},
|
attributes: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Состояние для управления порядком шаблонов
|
||||||
|
const orderState = useTemplateOrderState(templates, groups)
|
||||||
|
const templatesWithChanges = orderState.getTemplatesWithChanges()
|
||||||
|
|
||||||
|
// Состояние для drag and drop
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Настройка сенсоров для @dnd-kit
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8, // Небольшое расстояние для активации перетаскивания
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const toggleSelect = useCallback((id: string) => {
|
const toggleSelect = useCallback((id: string) => {
|
||||||
setSelected(prev => {
|
setSelected(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -308,13 +440,23 @@ export const TemplatesOverviewPage = () => {
|
|||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (newName.trim()) {
|
if (newName.trim()) {
|
||||||
try {
|
try {
|
||||||
// Создаем шаблон с атрибутами сразу
|
// Генерируем order для нового шаблона (в конец группы)
|
||||||
|
const newOrder = generateInitialOrder(templatesWithChanges, selectedGroupId)
|
||||||
|
|
||||||
|
console.log('Creating new template with order:', {
|
||||||
|
selectedGroupId,
|
||||||
|
newOrder
|
||||||
|
})
|
||||||
|
|
||||||
|
// Создаем шаблон с атрибутами, группой и order
|
||||||
await addTemplate(
|
await addTemplate(
|
||||||
{
|
{
|
||||||
name: newName,
|
name: newName,
|
||||||
elements: [],
|
elements: [],
|
||||||
description: newDescription,
|
description: newDescription,
|
||||||
mergedCells: [],
|
mergedCells: [],
|
||||||
|
group_id: selectedGroupId, // Добавляем выбранную группу
|
||||||
|
order: newOrder, // Добавляем order!
|
||||||
},
|
},
|
||||||
newAttributeValues.length > 0 ? newAttributeValues : undefined
|
newAttributeValues.length > 0 ? newAttributeValues : undefined
|
||||||
)
|
)
|
||||||
@@ -322,6 +464,7 @@ export const TemplatesOverviewPage = () => {
|
|||||||
setNewName('')
|
setNewName('')
|
||||||
setNewDescription('')
|
setNewDescription('')
|
||||||
setNewAttributeValues([])
|
setNewAttributeValues([])
|
||||||
|
setSelectedGroupId(null) // Сбрасываем выбранную группу
|
||||||
setIsDialogOpen(false)
|
setIsDialogOpen(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при создании шаблона:', error)
|
console.error('Ошибка при создании шаблона:', error)
|
||||||
@@ -335,6 +478,8 @@ export const TemplatesOverviewPage = () => {
|
|||||||
setTemplateToCopy(templateId)
|
setTemplateToCopy(templateId)
|
||||||
setCopyName(`${template.name} (копия)`)
|
setCopyName(`${template.name} (копия)`)
|
||||||
setCopyDescription(template.description || '')
|
setCopyDescription(template.description || '')
|
||||||
|
setCopyFirstSheetEmpty(false) // Сбрасываем галочку при открытии диалога
|
||||||
|
setCopySelectedGroupId(template.group_id || null) // Устанавливаем группу оригинала
|
||||||
|
|
||||||
// Загружаем атрибуты шаблона
|
// Загружаем атрибуты шаблона
|
||||||
loadTemplateAttributes(templateId).then(attributes => {
|
loadTemplateAttributes(templateId).then(attributes => {
|
||||||
@@ -352,11 +497,15 @@ export const TemplatesOverviewPage = () => {
|
|||||||
templateToCopy,
|
templateToCopy,
|
||||||
copyName,
|
copyName,
|
||||||
copyDescription,
|
copyDescription,
|
||||||
copyAttributeValues
|
copyAttributeValues,
|
||||||
|
copySelectedGroupId, // Передаем выбранную группу
|
||||||
|
copyFirstSheetEmpty
|
||||||
)
|
)
|
||||||
setCopyName('')
|
setCopyName('')
|
||||||
setCopyDescription('')
|
setCopyDescription('')
|
||||||
setCopyAttributeValues([])
|
setCopyAttributeValues([])
|
||||||
|
setCopyFirstSheetEmpty(false)
|
||||||
|
setCopySelectedGroupId(null)
|
||||||
setTemplateToCopy(null)
|
setTemplateToCopy(null)
|
||||||
setIsCopyDialogOpen(false)
|
setIsCopyDialogOpen(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -410,6 +559,22 @@ export const TemplatesOverviewPage = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEditGroup = (groupId: string) => {
|
||||||
|
setEditingGroupId(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteGroup = (groupId: string) => {
|
||||||
|
if (confirm('Вы уверены, что хотите удалить эту группу?')) {
|
||||||
|
deleteGroup(groupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateTemplate = (groupId?: string) => {
|
||||||
|
// Устанавливаем группу для нового шаблона
|
||||||
|
setSelectedGroupId(groupId || null)
|
||||||
|
setIsDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
const toggleSelectionMode = () => {
|
const toggleSelectionMode = () => {
|
||||||
setSelectionMode(v => {
|
setSelectionMode(v => {
|
||||||
if (v) setSelected(new Set())
|
if (v) setSelected(new Set())
|
||||||
@@ -417,9 +582,154 @@ export const TemplatesOverviewPage = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обработчики drag and drop для @dnd-kit
|
||||||
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
|
setActiveId(event.active.id as string)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
setActiveId(null)
|
||||||
|
|
||||||
|
if (!over || active.id === over.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTemplate = templatesWithChanges.find(t => t.id === active.id)
|
||||||
|
if (!activeTemplate) return
|
||||||
|
|
||||||
|
// Определяем, куда был сброшен элемент
|
||||||
|
const overId = over.id as string
|
||||||
|
|
||||||
|
console.log('🚀 DRAG END START:', {
|
||||||
|
activeId: active.id,
|
||||||
|
overId: overId,
|
||||||
|
activeTemplate: {
|
||||||
|
id: activeTemplate.id,
|
||||||
|
name: activeTemplate.name,
|
||||||
|
group_id: activeTemplate.group_id,
|
||||||
|
order: activeTemplate.order
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Проверяем, это drop на left/right зону карточки или на группу
|
||||||
|
if (overId.startsWith('drop-left-') || overId.startsWith('drop-right-')) {
|
||||||
|
// Определяем позицию (left/right) и ID карточки
|
||||||
|
const isLeftDrop = overId.startsWith('drop-left-')
|
||||||
|
const targetTemplateId = overId.replace(isLeftDrop ? 'drop-left-' : 'drop-right-', '')
|
||||||
|
const overTemplate = templatesWithChanges.find(t => t.id === targetTemplateId)
|
||||||
|
|
||||||
|
if (!overTemplate) return
|
||||||
|
|
||||||
|
const sourceGroupId = activeTemplate.group_id
|
||||||
|
const targetGroupId = overTemplate.group_id
|
||||||
|
|
||||||
|
console.log('🎯 Drop on card edge:', {
|
||||||
|
position: isLeftDrop ? 'left' : 'right',
|
||||||
|
targetTemplateId,
|
||||||
|
sourceGroupId,
|
||||||
|
targetGroupId
|
||||||
|
})
|
||||||
|
|
||||||
|
if ((sourceGroupId || null) === (targetGroupId || null)) {
|
||||||
|
// Перетаскивание внутри одной группы
|
||||||
|
const groupTemplates = templatesWithChanges
|
||||||
|
.filter(t => (t.group_id || null) === (sourceGroupId || null))
|
||||||
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
|
||||||
|
const activeIndex = groupTemplates.findIndex(t => t.id === active.id)
|
||||||
|
const targetIndex = groupTemplates.findIndex(t => t.id === targetTemplateId)
|
||||||
|
|
||||||
|
if (activeIndex !== targetIndex) {
|
||||||
|
// Определяем позицию вставки: слева (перед) или справа (после)
|
||||||
|
const insertIndex = isLeftDrop ? targetIndex : targetIndex + 1
|
||||||
|
const reorderedTemplates = arrayMove(groupTemplates, activeIndex, insertIndex)
|
||||||
|
|
||||||
|
// Пересчитываем order для всех элементов в группе
|
||||||
|
reorderedTemplates.forEach((template, index) => {
|
||||||
|
const newOrder = (index + 1) * 1000
|
||||||
|
if (template.order !== newOrder) {
|
||||||
|
orderState.applyOrderChange(template.id, newOrder, sourceGroupId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('🔄 Reordering within group:', {
|
||||||
|
groupId: sourceGroupId,
|
||||||
|
activeIndex,
|
||||||
|
targetIndex,
|
||||||
|
insertIndex,
|
||||||
|
position: isLeftDrop ? 'before' : 'after'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Перетаскивание между разными группами
|
||||||
|
const targetGroupTemplates = templatesWithChanges
|
||||||
|
.filter(t => (t.group_id || null) === (targetGroupId || null))
|
||||||
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
|
||||||
|
const targetIndex = targetGroupTemplates.findIndex(t => t.id === targetTemplateId)
|
||||||
|
|
||||||
|
let newOrder: number
|
||||||
|
|
||||||
|
if (isLeftDrop) {
|
||||||
|
// Вставка перед целевой карточкой (слева)
|
||||||
|
if (targetIndex === 0) {
|
||||||
|
// Перед первой карточкой
|
||||||
|
const firstOrder = targetGroupTemplates[0]?.order ?? 1000
|
||||||
|
newOrder = firstOrder > 1000 ? firstOrder / 2 : firstOrder - 500
|
||||||
|
} else {
|
||||||
|
// Между карточками
|
||||||
|
const prevOrder = targetGroupTemplates[targetIndex - 1]?.order ?? 0
|
||||||
|
const currentOrder = targetGroupTemplates[targetIndex]?.order ?? 1000
|
||||||
|
newOrder = (prevOrder + currentOrder) / 2
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Вставка после целевой карточки (справа)
|
||||||
|
const currentOrder = targetGroupTemplates[targetIndex]?.order ?? 1000
|
||||||
|
const nextOrder = targetGroupTemplates[targetIndex + 1]?.order ?? (currentOrder + 2000)
|
||||||
|
newOrder = (currentOrder + nextOrder) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔀 Moving between groups with edge positioning:', {
|
||||||
|
sourceGroupId,
|
||||||
|
targetGroupId,
|
||||||
|
targetTemplateId,
|
||||||
|
position: isLeftDrop ? 'before' : 'after',
|
||||||
|
newOrder
|
||||||
|
})
|
||||||
|
|
||||||
|
orderState.applyOrderChange(active.id as string, newOrder, targetGroupId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Сброс на пустую группу
|
||||||
|
const isOverGroup = groups.some(g => g.id === overId) || overId === 'ungrouped'
|
||||||
|
|
||||||
|
if (isOverGroup) {
|
||||||
|
const targetGroupId = overId === 'ungrouped' ? null : overId
|
||||||
|
const newOrder = generateInitialOrder(templatesWithChanges, targetGroupId)
|
||||||
|
|
||||||
|
console.log('🗂️ Moving to empty group:', {
|
||||||
|
targetGroupId,
|
||||||
|
newOrder
|
||||||
|
})
|
||||||
|
|
||||||
|
orderState.applyOrderChange(active.id as string, newOrder, targetGroupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [templatesWithChanges, orderState, groups])
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||||
|
// Можно добавить дополнительную логику если нужно
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Проверяем есть ли активные фильтры
|
||||||
|
const hasActiveFilters = useMemo(() => {
|
||||||
|
return !!(filters.name || filters.description || Object.keys(filters.attributes).length > 0)
|
||||||
|
}, [filters])
|
||||||
|
|
||||||
// Фильтрация шаблонов
|
// Фильтрация шаблонов
|
||||||
const filteredTemplates = useMemo(() => {
|
const filteredTemplates = useMemo(() => {
|
||||||
return templates.filter(template => {
|
return templatesWithChanges.filter(template => {
|
||||||
// Фильтр по названию
|
// Фильтр по названию
|
||||||
if (
|
if (
|
||||||
filters.name &&
|
filters.name &&
|
||||||
@@ -476,7 +786,73 @@ export const TemplatesOverviewPage = () => {
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}, [templates, filters])
|
}, [templatesWithChanges, filters])
|
||||||
|
|
||||||
|
// Группировка шаблонов по группам с правильной сортировкой
|
||||||
|
const groupedTemplates = useMemo(() => {
|
||||||
|
console.log('🗂️ Grouping templates, input:',
|
||||||
|
filteredTemplates.map(t => ({ id: t.id, name: t.name, group_id: t.group_id, order: t.order }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const grouped = groupTemplatesByGroup(filteredTemplates)
|
||||||
|
|
||||||
|
console.log('🗂️ Grouped templates (before sorting):',
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(grouped).map(([key, templates]) => [
|
||||||
|
key,
|
||||||
|
templates.map(t => ({ id: t.id, name: t.name, order: t.order }))
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сортируем шаблоны в каждой группе по порядку
|
||||||
|
const sortedGrouped: typeof grouped = {}
|
||||||
|
|
||||||
|
Object.keys(grouped).forEach(groupKey => {
|
||||||
|
sortedGrouped[groupKey] = grouped[groupKey].sort((a, b) => {
|
||||||
|
// Правильная обработка null/undefined порядков
|
||||||
|
const orderA = a.order ?? 999999
|
||||||
|
const orderB = b.order ?? 999999
|
||||||
|
return orderA - orderB
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('🗂️ Grouped templates (after sorting):',
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(sortedGrouped).map(([key, templates]) => [
|
||||||
|
key,
|
||||||
|
templates.map(t => ({ id: t.id, name: t.name, order: t.order }))
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return sortedGrouped
|
||||||
|
}, [filteredTemplates])
|
||||||
|
|
||||||
|
// Статистика фильтрации с учетом групп
|
||||||
|
const filterStats = useMemo(() => {
|
||||||
|
if (!hasActiveFilters) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTemplates = templatesWithChanges.length
|
||||||
|
const filteredCount = filteredTemplates.length
|
||||||
|
const filteredGroups = new Set(filteredTemplates.map(t => t.group_id || null))
|
||||||
|
const totalGroups = new Set(templatesWithChanges.map(t => t.group_id || null))
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTemplates,
|
||||||
|
filteredTemplates: filteredCount,
|
||||||
|
totalGroups: totalGroups.size,
|
||||||
|
groupsWithResults: filteredGroups.size,
|
||||||
|
emptyGroups: totalGroups.size - filteredGroups.size,
|
||||||
|
}
|
||||||
|
}, [hasActiveFilters, templatesWithChanges, filteredTemplates])
|
||||||
|
|
||||||
|
// Сортированные группы
|
||||||
|
const sortedGroups = useMemo(() => {
|
||||||
|
return [...groups].sort((a, b) => a.order - b.order)
|
||||||
|
}, [groups])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider defaultOpen={true}>
|
<SidebarProvider defaultOpen={true}>
|
||||||
@@ -492,6 +868,15 @@ export const TemplatesOverviewPage = () => {
|
|||||||
<h1 className="text-lg font-semibold">Карточки протоколов</h1>
|
<h1 className="text-lg font-semibold">Карточки протоколов</h1>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{/* Кнопка сохранения порядка */}
|
||||||
|
<OrderSaveButton
|
||||||
|
hasUnsavedChanges={orderState.hasUnsavedChanges}
|
||||||
|
isSaving={orderState.isSaving}
|
||||||
|
lastSaveError={orderState.lastSaveError}
|
||||||
|
onSave={() => orderState.saveOrderChanges(updateTemplateOrder, moveTemplateToGroup)}
|
||||||
|
onClearError={orderState.clearError}
|
||||||
|
changedCount={orderState.changedCount}
|
||||||
|
/>
|
||||||
{selectionMode && (
|
{selectionMode && (
|
||||||
<>
|
<>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
@@ -518,6 +903,11 @@ export const TemplatesOverviewPage = () => {
|
|||||||
|
|
||||||
{!selectionMode && (
|
{!selectionMode && (
|
||||||
<>
|
<>
|
||||||
|
<GroupManager
|
||||||
|
editingGroupId={editingGroupId}
|
||||||
|
onEditingGroupChange={setEditingGroupId}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -532,14 +922,8 @@ export const TemplatesOverviewPage = () => {
|
|||||||
Выбрать
|
Выбрать
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Диалог создания шаблона */}
|
{/* Диалог создания шаблона (перемещен в каждую группу) */}
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button size="sm">
|
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
|
||||||
Создать
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden">
|
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden">
|
||||||
<TemplateForm
|
<TemplateForm
|
||||||
title="Создать новый шаблон"
|
title="Создать новый шаблон"
|
||||||
@@ -558,6 +942,10 @@ export const TemplatesOverviewPage = () => {
|
|||||||
submitText="Создать"
|
submitText="Создать"
|
||||||
submitIcon={<Plus className="mr-2 h-4 w-4" />}
|
submitIcon={<Plus className="mr-2 h-4 w-4" />}
|
||||||
isSubmitDisabled={!newName.trim()}
|
isSubmitDisabled={!newName.trim()}
|
||||||
|
selectedGroupId={selectedGroupId}
|
||||||
|
onGroupChange={setSelectedGroupId}
|
||||||
|
showGroupSelector={true}
|
||||||
|
groups={groups}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -587,6 +975,13 @@ export const TemplatesOverviewPage = () => {
|
|||||||
isSubmitDisabled={!copyName.trim()}
|
isSubmitDisabled={!copyName.trim()}
|
||||||
nameLabel="Название копии"
|
nameLabel="Название копии"
|
||||||
descriptionLabel="Описание копии"
|
descriptionLabel="Описание копии"
|
||||||
|
selectedGroupId={copySelectedGroupId}
|
||||||
|
onGroupChange={setCopySelectedGroupId}
|
||||||
|
showGroupSelector={true}
|
||||||
|
groups={groups}
|
||||||
|
showCopyOptions={true}
|
||||||
|
copyFirstSheetEmpty={copyFirstSheetEmpty}
|
||||||
|
onCopyFirstSheetEmptyChange={setCopyFirstSheetEmpty}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -624,65 +1019,172 @@ export const TemplatesOverviewPage = () => {
|
|||||||
|
|
||||||
<main className="flex-1 p-6">
|
<main className="flex-1 p-6">
|
||||||
{/* Статистика фильтрации */}
|
{/* Статистика фильтрации */}
|
||||||
{(filters.name ||
|
{filterStats && (
|
||||||
filters.description ||
|
|
||||||
Object.keys(filters.attributes).length > 0) && (
|
|
||||||
<div className="mb-4 rounded-lg bg-muted/50 p-3">
|
<div className="mb-4 rounded-lg bg-muted/50 p-3">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Показано {filteredTemplates.length} из {templates.length}{' '}
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
шаблонов
|
<div>
|
||||||
|
Показано <span className="font-medium text-foreground">{filterStats.filteredTemplates}</span> из {filterStats.totalTemplates} шаблонов
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs">
|
||||||
|
<span>
|
||||||
|
Групп с результатами: <span className="font-medium text-foreground">{filterStats.groupsWithResults}</span>
|
||||||
|
</span>
|
||||||
|
{filterStats.emptyGroups > 0 && (
|
||||||
|
<span className="text-amber-600">
|
||||||
|
Пустых групп: {filterStats.emptyGroups}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{Object.keys(filters.attributes).length > 0 && (
|
{Object.keys(filters.attributes).length > 0 && (
|
||||||
<span className="ml-2">
|
<div className="mt-1 text-xs">
|
||||||
• Фильтры по атрибутам:{' '}
|
Активных фильтров по атрибутам: {Object.keys(filters.attributes).length}
|
||||||
{Object.keys(filters.attributes).length}
|
</div>
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section
|
{/* DndContext для @dnd-kit */}
|
||||||
className={clsx(
|
<DndContext
|
||||||
'grid gap-4',
|
sensors={sensors}
|
||||||
'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6'
|
collisionDetection={closestCenter}
|
||||||
)}
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
>
|
>
|
||||||
{filteredTemplates.map(t => (
|
{/* Отображение групп шаблонов */}
|
||||||
<TemplateCard
|
<div className={clsx(
|
||||||
key={t.id}
|
"space-y-6 transition-opacity duration-200",
|
||||||
template={t}
|
activeId && "opacity-90"
|
||||||
|
)}>
|
||||||
|
{/* Все группы - показываем все группы при фильтрации для сохранения контекста */}
|
||||||
|
{sortedGroups.map(group => {
|
||||||
|
const groupTemplates = groupedTemplates[group.id] || []
|
||||||
|
const showGroup = !hasActiveFilters || groupTemplates.length > 0 || hasActiveFilters
|
||||||
|
|
||||||
|
if (!showGroup) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TemplateGroup
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
templates={groupTemplates}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
selectedTemplates={selected}
|
||||||
|
onToggleSelect={toggleSelect}
|
||||||
|
onCopy={handleCopy}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onEditGroup={handleEditGroup}
|
||||||
|
onDeleteGroup={handleDeleteGroup}
|
||||||
|
activeFilters={filters.attributes}
|
||||||
|
isExpanded={orderState.getGroupExpanded(group.id)}
|
||||||
|
onToggleExpanded={() => orderState.toggleGroupExpanded(group.id)}
|
||||||
|
onCreateTemplate={handleCreateTemplate}
|
||||||
|
showEmptyState={hasActiveFilters && groupTemplates.length === 0}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Группа "Без группы" - показываем всегда */}
|
||||||
|
<TemplateGroup
|
||||||
|
group={null}
|
||||||
|
templates={groupedTemplates.ungrouped || []}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
isSelected={selected.has(t.id)}
|
selectedTemplates={selected}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
onCopy={handleCopy}
|
onCopy={handleCopy}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
activeFilters={filters.attributes}
|
activeFilters={filters.attributes}
|
||||||
|
isExpanded={orderState.getGroupExpanded(null)}
|
||||||
|
onToggleExpanded={() => orderState.toggleGroupExpanded(null)}
|
||||||
|
onCreateTemplate={handleCreateTemplate}
|
||||||
|
showEmptyState={hasActiveFilters && (!groupedTemplates.ungrouped || groupedTemplates.ungrouped.length === 0)}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{filteredTemplates.length === 0 && templates.length > 0 && (
|
{/* Если нет групп и нет шаблонов вообще, показываем кнопку создания */}
|
||||||
|
{sortedGroups.length === 0 && templatesWithChanges.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="text-muted-foreground mb-6">
|
||||||
|
<p className="text-lg font-medium">Нет групп и шаблонов</p>
|
||||||
|
<p className="text-sm">Создайте группу или шаблон для начала работы</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleCreateTemplate()}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Создать шаблон
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DragOverlay для красивого перетаскивания */}
|
||||||
|
<DragOverlay>
|
||||||
|
{activeId ? (
|
||||||
|
<div className={clsx(
|
||||||
|
'transform transition-transform duration-200',
|
||||||
|
'rotate-2 scale-105 opacity-90',
|
||||||
|
'drop-shadow-2xl shadow-2xl',
|
||||||
|
'pointer-events-none z-[9999]'
|
||||||
|
)}>
|
||||||
|
<div className={clsx(
|
||||||
|
'rounded-lg overflow-hidden',
|
||||||
|
'bg-background border-0',
|
||||||
|
// Полностью убираем все borders из вложенных элементов
|
||||||
|
'[&_*]:!border-0 [&_*]:!border-transparent [&_*]:!ring-0',
|
||||||
|
// Добавляем собственную тень и border
|
||||||
|
'ring-2 ring-primary/20 shadow-primary/10'
|
||||||
|
)}>
|
||||||
|
<TemplateCard
|
||||||
|
template={templatesWithChanges.find(t => t.id === activeId)!}
|
||||||
|
selectionMode={false}
|
||||||
|
isSelected={false}
|
||||||
|
activeFilters={filters.attributes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{filteredTemplates.length === 0 && templates.length > 0 && hasActiveFilters && (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground mb-6">
|
||||||
<p className="text-lg font-medium">Шаблоны не найдены</p>
|
<p className="text-lg font-medium">Шаблоны не найдены</p>
|
||||||
<p className="text-sm">Попробуйте изменить фильтры поиска</p>
|
<p className="text-sm">Ни один шаблон не соответствует заданным фильтрам</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setFilters({ name: '', description: '', attributes: {} })}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
Сбросить все фильтры
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{templates.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
<p className="text-lg font-medium">Пока нет шаблонов</p>
|
|
||||||
<p className="text-sm">Создайте свой первый шаблон протокола</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Основной экспортируемый компонент с провайдерами
|
||||||
|
export const TemplatesOverviewPage = () => {
|
||||||
|
return (
|
||||||
|
<GroupProvider>
|
||||||
|
<TemplatesPageContent />
|
||||||
|
</GroupProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default TemplatesOverviewPage
|
export default TemplatesOverviewPage
|
||||||
|
|||||||
@@ -402,16 +402,16 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
{template.elements.length === 0 && (
|
{template.elements.length === 0 && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="text-center text-gray-400">
|
<div className="text-center text-gray-400">
|
||||||
<FileText className="mx-auto mb-4 h-16 w-16" />
|
{/* <FileText className="mx-auto mb-4 h-16 w-16" /> */}
|
||||||
<h3 className="mb-2 text-lg font-medium text-gray-900">
|
<h3 className="mb-2 text-lg font-medium text-gray-900">
|
||||||
Нет элементов формы
|
Нет интерфейса
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mb-4 text-gray-600">
|
<p className="mb-4 text-gray-600">
|
||||||
В этом шаблоне пока не созданы элементы формы
|
В этом шаблоне пока не созданы элементы интерфейса
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => window.history.back()}
|
onClick={() => safeNavigate(`/templates/${template.id}/edit`)}
|
||||||
>
|
>
|
||||||
Настроить шаблон
|
Настроить шаблон
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -80,6 +80,16 @@ export interface TemplateAttributeDetail {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Группа шаблонов
|
||||||
|
export interface Group {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
order: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
deleted_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
// Типы шаблонов
|
// Типы шаблонов
|
||||||
export interface Template {
|
export interface Template {
|
||||||
id: string
|
id: string
|
||||||
@@ -90,6 +100,8 @@ export interface Template {
|
|||||||
mergedCells?: MergedCell[] // информация об объединенных ячейках
|
mergedCells?: MergedCell[] // информация об объединенных ячейках
|
||||||
elements: TemplateElement[]
|
elements: TemplateElement[]
|
||||||
layoutSettings?: FormLayoutSettings // настройки отображения формы
|
layoutSettings?: FormLayoutSettings // настройки отображения формы
|
||||||
|
group_id?: string | null // ID группы к которой принадлежит шаблон
|
||||||
|
order?: number // порядок сортировки (float для гибкости)
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,14 +59,14 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'group relative flex min-h-[160px] flex-col transition-all duration-200',
|
'group relative flex min-h-[160px] flex-col transition-all duration-300 ease-out',
|
||||||
(selectionMode || (!selectionMode && isConfigured)) && 'cursor-pointer',
|
(selectionMode || (!selectionMode && isConfigured)) && 'cursor-pointer',
|
||||||
selectionMode &&
|
selectionMode &&
|
||||||
isSelected &&
|
isSelected &&
|
||||||
'border-primary ring-2 ring-primary ring-offset-2',
|
'border-primary ring-2 ring-primary ring-offset-2',
|
||||||
!selectionMode &&
|
!selectionMode &&
|
||||||
isConfigured &&
|
isConfigured &&
|
||||||
'hover:border-primary/50 hover:shadow-md',
|
'hover:border-primary/40 hover:shadow-md',
|
||||||
!selectionMode && !isConfigured && 'cursor-not-allowed opacity-60'
|
!selectionMode && !isConfigured && 'cursor-not-allowed opacity-60'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -93,7 +93,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="absolute right-1 top-1 h-7 w-7 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
className="absolute right-1 top-1 h-7 w-7 p-0 opacity-0 transition-all duration-200 ease-out group-hover:opacity-100 hover:scale-110 hover:bg-accent/80"
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<MoreVertical className="h-3 w-3" />
|
<MoreVertical className="h-3 w-3" />
|
||||||
|
|||||||
Reference in New Issue
Block a user