689 lines
23 KiB
TypeScript
689 lines
23 KiB
TypeScript
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
|