группы
This commit is contained in:
@@ -43,7 +43,6 @@
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^18.2.0",
|
||||
"react-animated-numbers": "^1.1.1",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-day-picker": "^9.8.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"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
|
||||
value: string | null
|
||||
}>
|
||||
group_id?: string | null
|
||||
order?: number | null
|
||||
}
|
||||
|
||||
export interface CreateTemplateOutput {
|
||||
@@ -32,6 +34,8 @@ export interface PatchTemplateInput {
|
||||
attribute_id: string
|
||||
value: string | null
|
||||
}> | null
|
||||
group_id?: string | null | "ungroup"
|
||||
order?: number | null
|
||||
}
|
||||
|
||||
export interface PatchTemplateOutput {
|
||||
@@ -43,6 +47,8 @@ export interface ApiTemplate {
|
||||
name: string
|
||||
description?: string | null
|
||||
elements: Record<string, any>
|
||||
group_id?: string | null
|
||||
order?: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
deleted_at?: string | null
|
||||
@@ -74,7 +80,7 @@ export function apiTemplateToTemplate(apiTemplate: ApiTemplate): Template {
|
||||
// Преобразуем elements из Record<string, any> в TemplateElement[]
|
||||
const elements: TemplateElement[] = Object.values(apiTemplate.elements || {})
|
||||
.map(el => migrateTemplateElement(el))
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
|
||||
return {
|
||||
id: apiTemplate.id,
|
||||
@@ -82,6 +88,8 @@ export function apiTemplateToTemplate(apiTemplate: ApiTemplate): Template {
|
||||
description: apiTemplate.description || undefined,
|
||||
elements,
|
||||
layoutSettings: getDefaultLayoutSettings(),
|
||||
group_id: apiTemplate.group_id,
|
||||
order: apiTemplate.order, // ВАЖНО: добавляем order!
|
||||
createdAt: new Date(apiTemplate.created_at),
|
||||
updatedAt: new Date(apiTemplate.updated_at),
|
||||
}
|
||||
@@ -120,6 +128,8 @@ export function templateToCreateInput(
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
elements,
|
||||
group_id: template.group_id,
|
||||
order: template.order,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +144,8 @@ export function templateToPatchInput(template: Template): PatchTemplateInput {
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
elements,
|
||||
group_id: template.group_id,
|
||||
order: template.order,
|
||||
// attributes будут добавлены отдельно в TemplateContext при необходимости
|
||||
}
|
||||
}
|
||||
@@ -166,6 +178,8 @@ export async function createTemplateApi(
|
||||
// API получение всех шаблонов
|
||||
export async function getTemplatesApi(): Promise<GetTemplatesOutput> {
|
||||
try {
|
||||
console.log('🌐 GET API call to /api/templates/')
|
||||
|
||||
const response = await fetch('/api/templates/', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -173,11 +187,23 @@ export async function getTemplatesApi(): Promise<GetTemplatesOutput> {
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`🌐 GET API response status: ${response.status}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
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
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении шаблонов:', error)
|
||||
@@ -239,6 +265,8 @@ export async function patchTemplateApi(
|
||||
template: PatchTemplateInput
|
||||
): Promise<PatchTemplateOutput> {
|
||||
try {
|
||||
console.log(`🌐 PATCH API call to /api/templates/${templateId}`, template)
|
||||
|
||||
const response = await fetch(`/api/templates/${templateId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
@@ -247,11 +275,16 @@ export async function patchTemplateApi(
|
||||
body: JSON.stringify(template),
|
||||
})
|
||||
|
||||
console.log(`🌐 PATCH API response status: ${response.status}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`🌐 PATCH API error response:`, errorText)
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const result: PatchTemplateOutput = await response.json()
|
||||
console.log(`🌐 PATCH API success result:`, result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обновлении шаблона:', error)
|
||||
@@ -276,6 +309,8 @@ export interface CopyTemplateInput {
|
||||
attribute_id: string
|
||||
value: string | null
|
||||
}> | null
|
||||
group_id?: string | null
|
||||
copy_first_sheet_empty?: boolean
|
||||
}
|
||||
|
||||
export interface CopyTemplateOutput {
|
||||
|
||||
@@ -40,8 +40,12 @@ interface TemplateContextType {
|
||||
templateId: string,
|
||||
newName: string,
|
||||
newDescription?: string,
|
||||
attributes?: Array<{ attributeId: string; value: string | null }>
|
||||
attributes?: Array<{ attributeId: string; value: string | null }>,
|
||||
groupId?: string | null,
|
||||
copyFirstSheetEmpty?: boolean
|
||||
) => Promise<Template>
|
||||
updateTemplateOrder: (templateId: string, newOrder: number) => Promise<void>
|
||||
moveTemplateToGroup: (templateId: string, groupId: string | null) => Promise<void>
|
||||
}
|
||||
|
||||
const TemplateContext = createContext<TemplateContextType | undefined>(
|
||||
@@ -61,8 +65,60 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
|
||||
} = useQuery<Template[], Error>({
|
||||
queryKey: ['templates'],
|
||||
queryFn: async () => {
|
||||
console.log('🔄 RELOADING TEMPLATES FROM API')
|
||||
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,
|
||||
newDescription,
|
||||
attributes,
|
||||
groupId,
|
||||
copyFirstSheetEmpty,
|
||||
}: {
|
||||
templateId: string
|
||||
newName: string
|
||||
newDescription?: string
|
||||
attributes?: Array<{ attributeId: string; value: string | null }>
|
||||
groupId?: string | null
|
||||
copyFirstSheetEmpty?: boolean
|
||||
}) => {
|
||||
const input: CopyTemplateInput = {
|
||||
template_id: templateId,
|
||||
@@ -155,6 +215,8 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
|
||||
attribute_id: attr.attributeId,
|
||||
value: attr.value,
|
||||
})) || null,
|
||||
group_id: groupId,
|
||||
copy_first_sheet_empty: copyFirstSheetEmpty,
|
||||
}
|
||||
const { new_template_id } = await copyTemplateApi(input)
|
||||
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(
|
||||
() => ({
|
||||
templates,
|
||||
@@ -174,13 +300,17 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
|
||||
createMutation.status === 'pending' ||
|
||||
updateMutation.status === 'pending' ||
|
||||
deleteMutation.status === 'pending' ||
|
||||
copyMutation.status === 'pending',
|
||||
copyMutation.status === 'pending' ||
|
||||
updateOrderMutation.status === 'pending' ||
|
||||
moveToGroupMutation.status === 'pending',
|
||||
error:
|
||||
(errorList as Error) ||
|
||||
(createMutation.error as Error) ||
|
||||
(updateMutation.error as Error) ||
|
||||
(deleteMutation.error as Error) ||
|
||||
(copyMutation.error as Error) ||
|
||||
(updateOrderMutation.error as Error) ||
|
||||
(moveToGroupMutation.error as Error) ||
|
||||
null,
|
||||
refetchTemplates: refetch,
|
||||
addTemplate: async (
|
||||
@@ -204,14 +334,22 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
|
||||
templateId: string,
|
||||
newName: string,
|
||||
newDescription?: string,
|
||||
attributes?: Array<{ attributeId: string; value: string | null }>
|
||||
attributes?: Array<{ attributeId: string; value: string | null }>,
|
||||
groupId?: string | null,
|
||||
copyFirstSheetEmpty?: boolean
|
||||
) =>
|
||||
copyMutation.mutateAsync({
|
||||
templateId,
|
||||
newName,
|
||||
newDescription,
|
||||
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,
|
||||
@@ -225,11 +363,17 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
|
||||
deleteMutation.error,
|
||||
copyMutation.status,
|
||||
copyMutation.error,
|
||||
updateOrderMutation.status,
|
||||
updateOrderMutation.error,
|
||||
moveToGroupMutation.status,
|
||||
moveToGroupMutation.error,
|
||||
refetch,
|
||||
createMutation.mutateAsync,
|
||||
updateMutation.mutateAsync,
|
||||
deleteMutation.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
|
||||
}
|
||||
@@ -30,8 +30,8 @@ export function migrateTemplateElement(element: any): TemplateElement {
|
||||
return {
|
||||
...element,
|
||||
targetCells,
|
||||
order: element.order || 0,
|
||||
layout: element.layout || generateDefaultLayout(element.order || 0),
|
||||
order: element.order ?? 0,
|
||||
layout: element.layout || generateDefaultLayout(element.order ?? 0),
|
||||
} as TemplateElement
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
import { CategoryFieldsSelector } from '@/component/TemplateManager/CategoryFieldsSelector'
|
||||
import GroupManager from '@/component/TemplateManager/GroupManager'
|
||||
import { OrderSaveButton } from '@/component/TemplateManager/OrderSaveButton'
|
||||
import {
|
||||
TemplateFilterSidebar,
|
||||
TemplateFilters,
|
||||
} from '@/component/TemplateManager/TemplateFilterSidebar'
|
||||
import { TemplateGroup } from '@/component/TemplateManager/TemplateGroup'
|
||||
import { GroupProvider, useGroupContext } from '@/entity/group/model/GroupContext'
|
||||
import {
|
||||
apiTemplateWithAttributesToTemplate,
|
||||
getTemplateWithAttributesApi,
|
||||
} from '@/entity/template/api/templateApiService'
|
||||
import { useCategoryContext } from '@/entity/template/model/CategoryContext'
|
||||
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 {
|
||||
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 {
|
||||
CheckSquare,
|
||||
@@ -21,20 +43,28 @@ import {
|
||||
Tag,
|
||||
Trash2,
|
||||
Type,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Button } from 'shared/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from 'shared/ui/card'
|
||||
import { Checkbox } from 'shared/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogTitle
|
||||
} from 'shared/ui/dialog'
|
||||
import { Input } from 'shared/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from 'shared/ui/select'
|
||||
import { Separator } from 'shared/ui/separator'
|
||||
import {
|
||||
SidebarInset,
|
||||
@@ -121,6 +151,14 @@ interface TemplateFormProps {
|
||||
isSubmitDisabled: boolean
|
||||
nameLabel?: 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> = ({
|
||||
@@ -142,6 +180,13 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
|
||||
isSubmitDisabled,
|
||||
nameLabel = 'Название шаблона',
|
||||
descriptionLabel = 'Описание',
|
||||
selectedGroupId,
|
||||
onGroupChange,
|
||||
showGroupSelector = false,
|
||||
groups = [],
|
||||
showCopyOptions = false,
|
||||
copyFirstSheetEmpty = false,
|
||||
onCopyFirstSheetEmptyChange,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -171,6 +216,37 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
|
||||
className="h-9"
|
||||
/>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
@@ -192,6 +268,36 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
|
||||
/>
|
||||
</CardContent>
|
||||
</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>
|
||||
|
||||
<Separator className="my-4" />
|
||||
@@ -228,14 +334,17 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export const TemplatesOverviewPage = () => {
|
||||
const TemplatesPageContent = () => {
|
||||
const {
|
||||
templates,
|
||||
deleteTemplate,
|
||||
addTemplate,
|
||||
copyTemplate,
|
||||
updateTemplate,
|
||||
updateTemplateOrder,
|
||||
moveTemplateToGroup,
|
||||
} = useTemplateContext()
|
||||
const { groups, updateGroup, deleteGroup } = useGroupContext()
|
||||
const { isLoading: attributesLoading } = useCategoryContext()
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
@@ -250,11 +359,14 @@ export const TemplatesOverviewPage = () => {
|
||||
const [newAttributeValues, setNewAttributeValues] = useState<
|
||||
AttributeValue[]
|
||||
>([])
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null) // Добавляем состояние для выбранной группы
|
||||
const [copyName, setCopyName] = useState('')
|
||||
const [copyDescription, setCopyDescription] = useState('')
|
||||
const [copyAttributeValues, setCopyAttributeValues] = useState<
|
||||
AttributeValue[]
|
||||
>([])
|
||||
const [copyFirstSheetEmpty, setCopyFirstSheetEmpty] = useState(false)
|
||||
const [copySelectedGroupId, setCopySelectedGroupId] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editDescription, setEditDescription] = useState('')
|
||||
const [editAttributeValues, setEditAttributeValues] = useState<
|
||||
@@ -262,12 +374,32 @@ export const TemplatesOverviewPage = () => {
|
||||
>([])
|
||||
const [templateAttributesLoading, setTemplateAttributesLoading] =
|
||||
useState(false)
|
||||
const [editingGroupId, setEditingGroupId] = useState<string | null>(null)
|
||||
const [filters, setFilters] = useState<TemplateFilters>({
|
||||
name: '',
|
||||
description: '',
|
||||
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) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
@@ -308,13 +440,23 @@ export const TemplatesOverviewPage = () => {
|
||||
const handleCreate = async () => {
|
||||
if (newName.trim()) {
|
||||
try {
|
||||
// Создаем шаблон с атрибутами сразу
|
||||
// Генерируем order для нового шаблона (в конец группы)
|
||||
const newOrder = generateInitialOrder(templatesWithChanges, selectedGroupId)
|
||||
|
||||
console.log('Creating new template with order:', {
|
||||
selectedGroupId,
|
||||
newOrder
|
||||
})
|
||||
|
||||
// Создаем шаблон с атрибутами, группой и order
|
||||
await addTemplate(
|
||||
{
|
||||
name: newName,
|
||||
elements: [],
|
||||
description: newDescription,
|
||||
mergedCells: [],
|
||||
group_id: selectedGroupId, // Добавляем выбранную группу
|
||||
order: newOrder, // Добавляем order!
|
||||
},
|
||||
newAttributeValues.length > 0 ? newAttributeValues : undefined
|
||||
)
|
||||
@@ -322,6 +464,7 @@ export const TemplatesOverviewPage = () => {
|
||||
setNewName('')
|
||||
setNewDescription('')
|
||||
setNewAttributeValues([])
|
||||
setSelectedGroupId(null) // Сбрасываем выбранную группу
|
||||
setIsDialogOpen(false)
|
||||
} catch (error) {
|
||||
console.error('Ошибка при создании шаблона:', error)
|
||||
@@ -335,6 +478,8 @@ export const TemplatesOverviewPage = () => {
|
||||
setTemplateToCopy(templateId)
|
||||
setCopyName(`${template.name} (копия)`)
|
||||
setCopyDescription(template.description || '')
|
||||
setCopyFirstSheetEmpty(false) // Сбрасываем галочку при открытии диалога
|
||||
setCopySelectedGroupId(template.group_id || null) // Устанавливаем группу оригинала
|
||||
|
||||
// Загружаем атрибуты шаблона
|
||||
loadTemplateAttributes(templateId).then(attributes => {
|
||||
@@ -352,11 +497,15 @@ export const TemplatesOverviewPage = () => {
|
||||
templateToCopy,
|
||||
copyName,
|
||||
copyDescription,
|
||||
copyAttributeValues
|
||||
copyAttributeValues,
|
||||
copySelectedGroupId, // Передаем выбранную группу
|
||||
copyFirstSheetEmpty
|
||||
)
|
||||
setCopyName('')
|
||||
setCopyDescription('')
|
||||
setCopyAttributeValues([])
|
||||
setCopyFirstSheetEmpty(false)
|
||||
setCopySelectedGroupId(null)
|
||||
setTemplateToCopy(null)
|
||||
setIsCopyDialogOpen(false)
|
||||
} 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 = () => {
|
||||
setSelectionMode(v => {
|
||||
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(() => {
|
||||
return templates.filter(template => {
|
||||
return templatesWithChanges.filter(template => {
|
||||
// Фильтр по названию
|
||||
if (
|
||||
filters.name &&
|
||||
@@ -476,7 +786,73 @@ export const TemplatesOverviewPage = () => {
|
||||
|
||||
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 (
|
||||
<SidebarProvider defaultOpen={true}>
|
||||
@@ -492,6 +868,15 @@ export const TemplatesOverviewPage = () => {
|
||||
<h1 className="text-lg font-semibold">Карточки протоколов</h1>
|
||||
|
||||
<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 && (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
@@ -518,6 +903,11 @@ export const TemplatesOverviewPage = () => {
|
||||
|
||||
{!selectionMode && (
|
||||
<>
|
||||
<GroupManager
|
||||
editingGroupId={editingGroupId}
|
||||
onEditingGroupChange={setEditingGroupId}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -532,14 +922,8 @@ export const TemplatesOverviewPage = () => {
|
||||
Выбрать
|
||||
</Button>
|
||||
|
||||
{/* Диалог создания шаблона */}
|
||||
{/* Диалог создания шаблона (перемещен в каждую группу) */}
|
||||
<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">
|
||||
<TemplateForm
|
||||
title="Создать новый шаблон"
|
||||
@@ -558,6 +942,10 @@ export const TemplatesOverviewPage = () => {
|
||||
submitText="Создать"
|
||||
submitIcon={<Plus className="mr-2 h-4 w-4" />}
|
||||
isSubmitDisabled={!newName.trim()}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onGroupChange={setSelectedGroupId}
|
||||
showGroupSelector={true}
|
||||
groups={groups}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -587,6 +975,13 @@ export const TemplatesOverviewPage = () => {
|
||||
isSubmitDisabled={!copyName.trim()}
|
||||
nameLabel="Название копии"
|
||||
descriptionLabel="Описание копии"
|
||||
selectedGroupId={copySelectedGroupId}
|
||||
onGroupChange={setCopySelectedGroupId}
|
||||
showGroupSelector={true}
|
||||
groups={groups}
|
||||
showCopyOptions={true}
|
||||
copyFirstSheetEmpty={copyFirstSheetEmpty}
|
||||
onCopyFirstSheetEmptyChange={setCopyFirstSheetEmpty}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -624,65 +1019,172 @@ export const TemplatesOverviewPage = () => {
|
||||
|
||||
<main className="flex-1 p-6">
|
||||
{/* Статистика фильтрации */}
|
||||
{(filters.name ||
|
||||
filters.description ||
|
||||
Object.keys(filters.attributes).length > 0) && (
|
||||
{filterStats && (
|
||||
<div className="mb-4 rounded-lg bg-muted/50 p-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Показано {filteredTemplates.length} из {templates.length}{' '}
|
||||
шаблонов
|
||||
{Object.keys(filters.attributes).length > 0 && (
|
||||
<span className="ml-2">
|
||||
• Фильтры по атрибутам:{' '}
|
||||
{Object.keys(filters.attributes).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 && (
|
||||
<div className="mt-1 text-xs">
|
||||
Активных фильтров по атрибутам: {Object.keys(filters.attributes).length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section
|
||||
className={clsx(
|
||||
'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'
|
||||
)}
|
||||
{/* DndContext для @dnd-kit */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{filteredTemplates.map(t => (
|
||||
<TemplateCard
|
||||
key={t.id}
|
||||
template={t}
|
||||
{/* Отображение групп шаблонов */}
|
||||
<div className={clsx(
|
||||
"space-y-6 transition-opacity duration-200",
|
||||
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}
|
||||
isSelected={selected.has(t.id)}
|
||||
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}
|
||||
selectedTemplates={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
onCopy={handleCopy}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
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">
|
||||
<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="text-muted-foreground mb-6">
|
||||
<p className="text-lg font-medium">Шаблоны не найдены</p>
|
||||
<p className="text-sm">Попробуйте изменить фильтры поиска</p>
|
||||
<p className="text-sm">Ни один шаблон не соответствует заданным фильтрам</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setFilters({ name: '', description: '', attributes: {} })}
|
||||
className="gap-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Сбросить все фильтры
|
||||
</Button>
|
||||
</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>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Основной экспортируемый компонент с провайдерами
|
||||
export const TemplatesOverviewPage = () => {
|
||||
return (
|
||||
<GroupProvider>
|
||||
<TemplatesPageContent />
|
||||
</GroupProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default TemplatesOverviewPage
|
||||
|
||||
@@ -402,16 +402,16 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
||||
{template.elements.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<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>
|
||||
<p className="mb-4 text-gray-600">
|
||||
В этом шаблоне пока не созданы элементы формы
|
||||
В этом шаблоне пока не созданы элементы интерфейса
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.history.back()}
|
||||
onClick={() => safeNavigate(`/templates/${template.id}/edit`)}
|
||||
>
|
||||
Настроить шаблон
|
||||
</Button>
|
||||
|
||||
@@ -80,6 +80,16 @@ export interface TemplateAttributeDetail {
|
||||
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 {
|
||||
id: string
|
||||
@@ -90,6 +100,8 @@ export interface Template {
|
||||
mergedCells?: MergedCell[] // информация об объединенных ячейках
|
||||
elements: TemplateElement[]
|
||||
layoutSettings?: FormLayoutSettings // настройки отображения формы
|
||||
group_id?: string | null // ID группы к которой принадлежит шаблон
|
||||
order?: number // порядок сортировки (float для гибкости)
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
@@ -59,14 +59,14 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
: undefined
|
||||
}
|
||||
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 &&
|
||||
isSelected &&
|
||||
'border-primary ring-2 ring-primary ring-offset-2',
|
||||
!selectionMode &&
|
||||
isConfigured &&
|
||||
'hover:border-primary/50 hover:shadow-md',
|
||||
'hover:border-primary/40 hover:shadow-md',
|
||||
!selectionMode && !isConfigured && 'cursor-not-allowed opacity-60'
|
||||
)}
|
||||
>
|
||||
@@ -93,7 +93,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
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()}
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
|
||||
Reference in New Issue
Block a user