Files
protoc-frontend/src/page/TemplatesOverviewPage.tsx

689 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CategoryFieldsSelector } from '@/component/TemplateManager/CategoryFieldsSelector'
import {
TemplateFilterSidebar,
TemplateFilters,
} from '@/component/TemplateManager/TemplateFilterSidebar'
import { Button } from '@/component/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/component/ui/dialog'
import { Input } from '@/component/ui/input'
import { Separator } from '@/component/ui/separator'
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from '@/component/ui/sidebar'
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 TemplateCard from '@/widget/template/ui/TemplateCard'
import clsx from 'clsx'
import {
CheckSquare,
Copy,
Edit3,
Plus,
Square,
Tag,
Trash2,
Type,
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
interface AttributeValue {
attributeId: string
value: string | null
}
// Тип для CategoryFieldsSelector
interface CategoryAttributeValue {
attributeId: string
value: string | number
}
// Адаптеры для преобразования типов
const toCategoryAttributeValues = (
values: AttributeValue[]
): CategoryAttributeValue[] => {
return values.map(v => ({
attributeId: v.attributeId,
value: v.value || '',
}))
}
const fromCategoryAttributeValues = (
values: CategoryAttributeValue[]
): AttributeValue[] => {
return values.map(v => ({
attributeId: v.attributeId,
value: v.value?.toString() || null,
}))
}
// Компактный селектор атрибутов
interface CompactCategoryFieldsSelectorProps {
selectedValues: CategoryAttributeValue[]
onValuesChange: (values: CategoryAttributeValue[]) => void
isLoading?: boolean
}
const CompactCategoryFieldsSelector: React.FC<
CompactCategoryFieldsSelectorProps
> = ({ selectedValues, onValuesChange, isLoading = false }) => {
if (isLoading) {
return (
<div className="py-4 text-center text-sm text-muted-foreground">
Загрузка атрибутов...
</div>
)
}
return (
<div className="space-y-3">
<CategoryFieldsSelector
selectedValues={selectedValues}
onValuesChange={onValuesChange}
compact={true} // Передаем флаг для компактного отображения
/>
</div>
)
}
// Общий компонент формы шаблона
interface TemplateFormProps {
title: string
description: string
icon: React.ReactNode
iconColor: string
name: string
onNameChange: (value: string) => void
templateDescription: string
onDescriptionChange: (value: string) => void
attributeValues: AttributeValue[]
onAttributeValuesChange: (values: AttributeValue[]) => void
attributesLoading: boolean
onSubmit: () => void | Promise<void>
onCancel: () => void
submitText: string
submitIcon: React.ReactNode
isSubmitDisabled: boolean
nameLabel?: string
descriptionLabel?: string
}
const TemplateForm: React.FC<TemplateFormProps> = ({
title,
description,
icon,
iconColor,
name,
onNameChange,
templateDescription,
onDescriptionChange,
attributeValues,
onAttributeValuesChange,
attributesLoading,
onSubmit,
onCancel,
submitText,
submitIcon,
isSubmitDisabled,
nameLabel = 'Название шаблона',
descriptionLabel = 'Описание',
}) => {
return (
<>
<DialogHeader className="pb-4">
<DialogTitle className="flex items-center gap-2 text-lg">
{icon}
{title}
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="max-h-[50vh] space-y-4 overflow-y-auto pr-2">
{/* Основные настройки */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Type className={`h-4 w-4 ${iconColor}`} />
<CardTitle className="text-sm">{nameLabel}</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Input
placeholder={`Введите ${nameLabel.toLowerCase()}`}
value={name}
onChange={e => onNameChange(e.target.value)}
className="h-9"
/>
</div>
</CardContent>
</Card>
{/* Атрибуты */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-purple-600" />
<CardTitle className="text-sm">Атрибуты шаблона</CardTitle>
</div>
</CardHeader>
<CardContent>
<CompactCategoryFieldsSelector
selectedValues={toCategoryAttributeValues(attributeValues)}
onValuesChange={values =>
onAttributeValuesChange(fromCategoryAttributeValues(values))
}
isLoading={attributesLoading}
/>
</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">
Заполните {nameLabel.toLowerCase()}
</span>
)}
</div>
<div className="flex gap-3">
<DialogClose asChild>
<Button variant="outline">Отмена</Button>
</DialogClose>
<Button
onClick={() => {
const result = onSubmit()
if (result instanceof Promise) {
result.catch(error => {
console.error('Ошибка при выполнении операции:', error)
})
}
}}
disabled={isSubmitDisabled}
className="min-w-[130px]"
>
{submitIcon}
{submitText}
</Button>
</div>
</div>
</>
)
}
export const TemplatesOverviewPage = () => {
const {
templates,
deleteTemplate,
addTemplate,
copyTemplate,
updateTemplate,
} = useTemplateContext()
const { isLoading: attributesLoading } = useCategoryContext()
const [selected, setSelected] = useState<Set<string>>(new Set())
const [selectionMode, setSelectionMode] = useState(false)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCopyDialogOpen, setIsCopyDialogOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [templateToCopy, setTemplateToCopy] = useState<string | null>(null)
const [templateToEdit, setTemplateToEdit] = useState<string | null>(null)
const [newName, setNewName] = useState('')
const [newDescription, setNewDescription] = useState('')
const [newAttributeValues, setNewAttributeValues] = useState<
AttributeValue[]
>([])
const [copyName, setCopyName] = useState('')
const [copyDescription, setCopyDescription] = useState('')
const [copyAttributeValues, setCopyAttributeValues] = useState<
AttributeValue[]
>([])
const [editName, setEditName] = useState('')
const [editDescription, setEditDescription] = useState('')
const [editAttributeValues, setEditAttributeValues] = useState<
AttributeValue[]
>([])
const [templateAttributesLoading, setTemplateAttributesLoading] =
useState(false)
const [filters, setFilters] = useState<TemplateFilters>({
name: '',
description: '',
attributes: {},
})
const toggleSelect = useCallback((id: string) => {
setSelected(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}, [])
// Функция для загрузки атрибутов шаблона
const loadTemplateAttributes = async (templateId: string) => {
try {
setTemplateAttributesLoading(true)
const { template } = await getTemplateWithAttributesApi(templateId)
if (template) {
const templateWithAttrs = apiTemplateWithAttributesToTemplate(template)
const attributeValues: AttributeValue[] =
templateWithAttrs.attributes.map(attr => ({
attributeId: attr.attribute_id,
value: attr.value,
}))
return attributeValues
}
return []
} catch (error) {
console.error('Ошибка при загрузке атрибутов шаблона:', error)
return []
} finally {
setTemplateAttributesLoading(false)
}
}
const removeSelected = () => {
selected.forEach(deleteTemplate)
setSelected(new Set())
setSelectionMode(false)
}
const handleCreate = async () => {
if (newName.trim()) {
try {
// Создаем шаблон с атрибутами сразу
await addTemplate(
{
name: newName,
elements: [],
description: newDescription,
mergedCells: [],
},
newAttributeValues.length > 0 ? newAttributeValues : undefined
)
setNewName('')
setNewDescription('')
setNewAttributeValues([])
setIsDialogOpen(false)
} catch (error) {
console.error('Ошибка при создании шаблона:', error)
}
}
}
const handleCopy = (templateId: string) => {
const template = templates.find(t => t.id === templateId)
if (template) {
setTemplateToCopy(templateId)
setCopyName(`${template.name} (копия)`)
setCopyDescription(template.description || '')
// Загружаем атрибуты шаблона
loadTemplateAttributes(templateId).then(attributes => {
setCopyAttributeValues(attributes)
})
setIsCopyDialogOpen(true)
}
}
const handleCopyConfirm = async () => {
if (templateToCopy && copyName.trim()) {
try {
await copyTemplate(
templateToCopy,
copyName,
copyDescription,
copyAttributeValues
)
setCopyName('')
setCopyDescription('')
setCopyAttributeValues([])
setTemplateToCopy(null)
setIsCopyDialogOpen(false)
} catch (error) {
console.error('Ошибка при копировании шаблона:', error)
}
}
}
const handleEdit = (templateId: string) => {
const template = templates.find(t => t.id === templateId)
if (template) {
setTemplateToEdit(templateId)
setEditName(template.name)
setEditDescription(template.description || '')
// Загружаем атрибуты шаблона
loadTemplateAttributes(templateId).then(attributes => {
setEditAttributeValues(attributes)
})
setIsEditDialogOpen(true)
}
}
const handleEditConfirm = async () => {
if (templateToEdit && editName.trim()) {
try {
const template = templates.find(t => t.id === templateToEdit)
if (template) {
const updatedTemplate = {
...template,
name: editName,
description: editDescription,
}
await updateTemplate(updatedTemplate, editAttributeValues)
setEditName('')
setEditDescription('')
setEditAttributeValues([])
setTemplateToEdit(null)
setIsEditDialogOpen(false)
}
} catch (error) {
console.error('Ошибка при редактировании шаблона:', error)
}
}
}
const handleDelete = (templateId: string) => {
if (confirm('Вы уверены, что хотите удалить этот шаблон?')) {
deleteTemplate(templateId)
}
}
const toggleSelectionMode = () => {
setSelectionMode(v => {
if (v) setSelected(new Set())
return !v
})
}
// Фильтрация шаблонов
const filteredTemplates = useMemo(() => {
return templates.filter(template => {
// Фильтр по названию
if (
filters.name &&
!template.name.toLowerCase().includes(filters.name.toLowerCase())
) {
return false
}
// Фильтр по описанию
if (
filters.description &&
(!template.description ||
!template.description
.toLowerCase()
.includes(filters.description.toLowerCase()))
) {
return false
}
// Фильтрация по атрибутам
if (Object.keys(filters.attributes).length > 0) {
// Проверяем есть ли атрибуты у шаблона
const templateWithAttrs = template as Template & {
attributes?: TemplateAttributeDetail[]
}
if (!templateWithAttrs.attributes) {
return false
}
// Проверяем соответствуют ли атрибуты фильтрам
for (const [attributeId, filterValue] of Object.entries(
filters.attributes
)) {
if (!filterValue) continue // пропускаем пустые фильтры
const templateAttribute = templateWithAttrs.attributes.find(
(attr: TemplateAttributeDetail) => attr.attribute_id === attributeId
)
if (!templateAttribute || !templateAttribute.value) {
return false // атрибут не найден или пустой
}
// Проверяем содержится ли значение фильтра в значении атрибута
if (
!templateAttribute.value
.toLowerCase()
.includes(filterValue.toLowerCase())
) {
return false
}
}
}
return true
})
}, [templates, filters])
return (
<SidebarProvider defaultOpen={true}>
<TemplateFilterSidebar
templates={templates}
filters={filters}
onFiltersChange={setFilters}
/>
<SidebarInset>
<header className="flex items-center gap-2 border-b px-4 py-2">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-2 h-4" />
<h1 className="text-lg font-semibold">Карточки протоколов</h1>
<div className="ml-auto flex items-center gap-2">
{selectionMode && (
<>
<span className="text-sm text-muted-foreground">
Выбрано: {selected.size}
</span>
<Button
size="sm"
onClick={removeSelected}
disabled={selected.size === 0}
variant="destructive"
>
<Trash2 className="mr-1 h-4 w-4" />
Удалить
</Button>
<Button
size="sm"
variant="outline"
onClick={toggleSelectionMode}
>
Отмена
</Button>
</>
)}
{!selectionMode && (
<>
<Button
size="sm"
variant="outline"
onClick={toggleSelectionMode}
disabled={templates.length === 0}
>
{selected.size === templates.length ? (
<Square className="mr-1 h-4 w-4" />
) : (
<CheckSquare className="mr-1 h-4 w-4" />
)}
Выбрать
</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="Создать новый шаблон"
description="Заполните основную информацию о шаблоне протокола"
icon={<Plus className="h-5 w-5 text-blue-600" />}
iconColor="text-blue-600"
name={newName}
onNameChange={setNewName}
templateDescription={newDescription}
onDescriptionChange={setNewDescription}
attributeValues={newAttributeValues}
onAttributeValuesChange={setNewAttributeValues}
attributesLoading={attributesLoading}
onSubmit={handleCreate}
onCancel={() => setIsDialogOpen(false)}
submitText="Создать"
submitIcon={<Plus className="mr-2 h-4 w-4" />}
isSubmitDisabled={!newName.trim()}
/>
</DialogContent>
</Dialog>
{/* Диалог копирования */}
<Dialog
open={isCopyDialogOpen}
onOpenChange={setIsCopyDialogOpen}
>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden">
<TemplateForm
title="Копировать шаблон"
description="Укажите название и описание для копии шаблона"
icon={<Copy className="h-5 w-5 text-green-600" />}
iconColor="text-green-600"
name={copyName}
onNameChange={setCopyName}
templateDescription={copyDescription}
onDescriptionChange={setCopyDescription}
attributeValues={copyAttributeValues}
onAttributeValuesChange={setCopyAttributeValues}
attributesLoading={attributesLoading}
onSubmit={handleCopyConfirm}
onCancel={() => setIsCopyDialogOpen(false)}
submitText="Копировать"
submitIcon={<Copy className="mr-2 h-4 w-4" />}
isSubmitDisabled={!copyName.trim()}
nameLabel="Название копии"
descriptionLabel="Описание копии"
/>
</DialogContent>
</Dialog>
{/* Диалог редактирования */}
<Dialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden">
<TemplateForm
title="Редактировать шаблон"
description="Измените основную информацию о шаблоне"
icon={<Edit3 className="h-5 w-5 text-orange-600" />}
iconColor="text-orange-600"
name={editName}
onNameChange={setEditName}
templateDescription={editDescription}
onDescriptionChange={setEditDescription}
attributeValues={editAttributeValues}
onAttributeValuesChange={setEditAttributeValues}
attributesLoading={attributesLoading}
onSubmit={handleEditConfirm}
onCancel={() => setIsEditDialogOpen(false)}
submitText="Сохранить"
submitIcon={<Edit3 className="mr-2 h-4 w-4" />}
isSubmitDisabled={!editName.trim()}
/>
</DialogContent>
</Dialog>
</>
)}
</div>
</header>
<main className="flex-1 p-6">
{/* Статистика фильтрации */}
{(filters.name ||
filters.description ||
Object.keys(filters.attributes).length > 0) && (
<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}
</span>
)}
</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'
)}
>
{filteredTemplates.map(t => (
<TemplateCard
key={t.id}
template={t}
selectionMode={selectionMode}
isSelected={selected.has(t.id)}
onToggleSelect={toggleSelect}
onCopy={handleCopy}
onEdit={handleEdit}
onDelete={handleDelete}
activeFilters={filters.attributes}
/>
))}
</section>
{filteredTemplates.length === 0 && 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>
)}
{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 default TemplatesOverviewPage