группы

This commit is contained in:
Artem
2025-08-03 15:19:07 +03:00
parent 95e89b27bf
commit 4d69217967
17 changed files with 3549 additions and 84 deletions

1246
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View 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

View 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'

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

View 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('Ошибка при удалении группы на сервере')
}
}

View 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
}

View File

@@ -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
@@ -62,9 +68,9 @@ export interface ApiTemplateWithAttributes extends ApiTemplate {
// Импортируем типы для преобразования
import { getDefaultLayoutSettings, migrateTemplateElement } from '@/lib/utils'
import {
Template,
TemplateAttributeDetail,
TemplateElement,
Template,
TemplateAttributeDetail,
TemplateElement,
} from '@/type/template'
// Утилитарные функции для преобразования данных
@@ -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 {

View File

@@ -1,22 +1,22 @@
import {
apiTemplateToTemplate,
apiTemplateWithAttributesToTemplate,
copyTemplateApi,
CopyTemplateInput,
createTemplateApi,
deleteTemplateApi,
getTemplateApi,
getTemplatesApi,
patchTemplateApi,
templateToCreateInput,
templateToPatchInput,
apiTemplateToTemplate,
apiTemplateWithAttributesToTemplate,
copyTemplateApi,
CopyTemplateInput,
createTemplateApi,
deleteTemplateApi,
getTemplateApi,
getTemplatesApi,
patchTemplateApi,
templateToCreateInput,
templateToPatchInput,
} from '@/entity/template/api/templateApiService'
import { Template } from '@/type/template'
import {
QueryClient,
useMutation,
useQuery,
useQueryClient,
QueryClient,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import React, { createContext, ReactNode, useContext, useMemo } from 'react'
@@ -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,
]
)

View 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
View 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
View 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
}

View File

@@ -1,8 +1,8 @@
import {
CellTarget,
ElementLayout,
FormLayoutSettings,
TemplateElement,
CellTarget,
ElementLayout,
FormLayoutSettings,
TemplateElement,
} from '@/type/template'
// Миграция элементов из старого формата в новый
@@ -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
}

View File

@@ -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}{' '}
шаблонов
<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 && (
<span className="ml-2">
Фильтры по атрибутам:{' '}
{Object.keys(filters.attributes).length}
</span>
<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}
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}
isSelected={selected.has(t.id)}
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 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">
<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

View File

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

View File

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

View File

@@ -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" />