фильтры

This commit is contained in:
2025-07-27 07:39:01 +03:00
parent f5b2a5e6ee
commit bfaba2723c
5 changed files with 736 additions and 77 deletions

View File

@@ -112,7 +112,6 @@ export const TemplateFilterSidebar: React.FC<TemplateFilterSidebarProps> = ({
value={filters.name}
onValueChange={value => updateFilters({ name: value })}
suggestions={nameSuggestions}
placeholder="Введите название"
/>
</SidebarGroupContent>
</SidebarGroup>
@@ -128,7 +127,6 @@ export const TemplateFilterSidebar: React.FC<TemplateFilterSidebarProps> = ({
value={filters.description}
onValueChange={value => updateFilters({ description: value })}
suggestions={descriptionSuggestions}
placeholder="Введите описание"
/>
</SidebarGroupContent>
</SidebarGroup>
@@ -187,7 +185,6 @@ export const TemplateFilterSidebar: React.FC<TemplateFilterSidebarProps> = ({
updateAttributeFilter(attribute.id, value)
}
suggestions={[]} // Пока без автодополнения
placeholder={`Фильтр по ${attribute.name.toLowerCase()}`}
/>
</div>
))}

View File

@@ -9,7 +9,7 @@ export interface CreateTemplateOutput {
}
export interface GetTemplatesOutput {
templates: ApiTemplate[]
templates: ApiTemplateWithAttributes[]
}
export interface GetTemplateOutput {
@@ -24,6 +24,10 @@ export interface PatchTemplateInput {
name?: string | null
description?: string | null
elements?: Record<string, any> | null
attributes?: Array<{
attribute_id: string
value: string | null
}> | null
}
export interface PatchTemplateOutput {
@@ -126,6 +130,7 @@ export function templateToPatchInput(template: Template): PatchTemplateInput {
name: template.name,
description: template.description,
elements,
// attributes будут добавлены отдельно в TemplateContext при необходимости
}
}
@@ -178,7 +183,7 @@ export async function getTemplatesApi(): Promise<GetTemplatesOutput> {
// API получение конкретного шаблона
export async function getTemplateApi(
templateId: UUID
templateId: string
): Promise<GetTemplateOutput> {
try {
const response = await fetch(`/api/templates/${templateId}`, {
@@ -202,18 +207,15 @@ export async function getTemplateApi(
// API получение шаблона с атрибутами
export async function getTemplateWithAttributesApi(
templateId: UUID
templateId: string
): Promise<GetTemplateWithAttributesOutput> {
try {
const response = await fetch(
`/api/templates/${templateId}/with-attributes`,
{
const response = await fetch(`/api/templates/${templateId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
)
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
@@ -260,3 +262,73 @@ export async function updateTemplate(
const patchInput = templateToPatchInput(template)
return patchTemplateApi(template.id, patchInput)
}
// Интерфейсы для копирования шаблона
export interface CopyTemplateInput {
template_id: string
new_name: string
new_description?: string | null
new_attributes?: Array<{
attribute_id: string
value: string | null
}> | null
}
export interface CopyTemplateOutput {
new_template_id: string
}
// Интерфейсы для удаления шаблона
export interface DeleteTemplateOutput {
success: boolean
template_id: string
}
// API копирование шаблона
export async function copyTemplateApi(
input: CopyTemplateInput
): Promise<CopyTemplateOutput> {
try {
const response = await fetch('/api/templates/copy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: CopyTemplateOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при копировании шаблона:', error)
throw new Error('Ошибка при копировании шаблона на сервере')
}
}
// API удаление шаблона
export async function deleteTemplateApi(
templateId: string
): Promise<DeleteTemplateOutput> {
try {
const response = await fetch(`/api/templates/${templateId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: DeleteTemplateOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при удалении шаблона:', error)
throw new Error('Ошибка при удалении шаблона на сервере')
}
}

View File

@@ -1,6 +1,10 @@
import {
apiTemplateToTemplate,
apiTemplateWithAttributesToTemplate,
copyTemplateApi,
CopyTemplateInput,
createTemplateApi,
deleteTemplateApi,
getTemplateApi,
getTemplatesApi,
patchTemplateApi,
@@ -26,8 +30,17 @@ interface TemplateContextType {
addTemplate: (
data: Omit<Template, 'id' | 'createdAt' | 'updatedAt'>
) => Promise<Template>
updateTemplate: (template: Template) => Promise<Template>
deleteTemplate: (id: UUID) => Promise<string>
updateTemplate: (
template: Template,
attributes?: Array<{ attributeId: string; value: string | null }>
) => Promise<Template>
deleteTemplate: (id: string) => Promise<string>
copyTemplate: (
templateId: string,
newName: string,
newDescription?: string,
attributes?: Array<{ attributeId: string; value: string | null }>
) => Promise<Template>
}
const TemplateContext = createContext<TemplateContextType | undefined>(
@@ -48,7 +61,7 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
queryKey: ['templates'],
queryFn: async () => {
const { templates: apiList } = await getTemplatesApi()
return apiList.map(apiTemplateToTemplate)
return apiList.map(apiTemplateWithAttributesToTemplate)
},
})
@@ -68,8 +81,20 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
})
const updateMutation = useMutation({
mutationFn: async (template: Template) => {
mutationFn: async ({
template,
attributes,
}: {
template: Template
attributes?: Array<{ attributeId: string; value: string | null }>
}) => {
const input = templateToPatchInput(template)
if (attributes) {
input.attributes = attributes.map(attr => ({
attribute_id: attr.attributeId,
value: attr.value,
}))
}
await patchTemplateApi(template.id, input)
return template
},
@@ -84,7 +109,10 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
// TODO: deleteTemplateApi
const result = await deleteTemplateApi(id)
if (!result.success) {
throw new Error('Не удалось удалить шаблон')
}
return id
},
onSuccess: id => {
@@ -95,6 +123,38 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
},
})
const copyMutation = useMutation({
mutationFn: async ({
templateId,
newName,
newDescription,
attributes,
}: {
templateId: string
newName: string
newDescription?: string
attributes?: Array<{ attributeId: string; value: string | null }>
}) => {
const input: CopyTemplateInput = {
template_id: templateId,
new_name: newName,
new_description: newDescription || null,
new_attributes:
attributes?.map(attr => ({
attribute_id: attr.attributeId,
value: attr.value,
})) || null,
}
const { new_template_id } = await copyTemplateApi(input)
const { template: apiTpl } = await getTemplateApi(new_template_id)
if (!apiTpl) throw new Error('Copied template not found')
return apiTemplateToTemplate(apiTpl)
},
onSuccess: () => {
client.invalidateQueries({ queryKey: ['templates'] })
},
})
const value = useMemo(
() => ({
templates,
@@ -102,17 +162,38 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
listStatus === 'pending' ||
createMutation.status === 'pending' ||
updateMutation.status === 'pending' ||
deleteMutation.status === 'pending',
deleteMutation.status === 'pending' ||
copyMutation.status === 'pending',
error:
(errorList as Error) ||
(createMutation.error as Error) ||
(updateMutation.error as Error) ||
(deleteMutation.error as Error) ||
(copyMutation.error as Error) ||
null,
refetchTemplates: refetch,
addTemplate: createMutation.mutateAsync,
updateTemplate: updateMutation.mutateAsync,
updateTemplate: async (
template: Template,
attributes?: Array<{ attributeId: string; value: string | null }>
) =>
updateMutation.mutateAsync({
template,
attributes,
}),
deleteTemplate: deleteMutation.mutateAsync,
copyTemplate: async (
templateId: string,
newName: string,
newDescription?: string,
attributes?: Array<{ attributeId: string; value: string | null }>
) =>
copyMutation.mutateAsync({
templateId,
newName,
newDescription,
attributes,
}),
}),
[
templates,
@@ -124,10 +205,13 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
updateMutation.error,
deleteMutation.status,
deleteMutation.error,
copyMutation.status,
copyMutation.error,
refetch,
createMutation.mutateAsync,
updateMutation.mutateAsync,
deleteMutation.mutateAsync,
copyMutation.mutateAsync,
]
)

View File

@@ -23,30 +23,82 @@ import {
SidebarTrigger,
} from '@/component/ui/sidebar'
import { Textarea } from '@/component/ui/textarea'
import {
apiTemplateWithAttributesToTemplate,
getTemplateWithAttributesApi,
} from '@/entitiy/template/api/templateApiService'
import { useCategoryContext } from '@/entitiy/template/model/CategoryContext'
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
import { Template, TemplateAttributeDetail } from '@/type/template'
import TemplateCard from '@/widget/template/ui/TemplateCard'
import clsx from 'clsx'
import { CheckSquare, Plus, Square, Trash2 } 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,
}))
}
export const TemplatesOverviewPage = () => {
const { templates, deleteTemplate, addTemplate } = useTemplateContext()
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: '',
@@ -61,6 +113,29 @@ export const TemplatesOverviewPage = () => {
})
}, [])
// Функция для загрузки атрибутов шаблона
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())
@@ -82,6 +157,87 @@ export const TemplatesOverviewPage = () => {
}
}
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())
@@ -111,9 +267,40 @@ export const TemplatesOverviewPage = () => {
return false
}
// Примечание: Фильтрация по атрибутам пока отключена,
// так как атрибуты теперь хранятся отдельно от шаблонов
// и требуют отдельного запроса к API для каждого шаблона
// Фильтрация по атрибутам
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
})
@@ -222,8 +409,14 @@ export const TemplatesOverviewPage = () => {
<div className="col-span-3">
{!attributesLoading && (
<CategoryFieldsSelector
selectedValues={newAttributeValues}
onValuesChange={setNewAttributeValues}
selectedValues={toCategoryAttributeValues(
newAttributeValues
)}
onValuesChange={values =>
setNewAttributeValues(
fromCategoryAttributeValues(values)
)
}
/>
)}
</div>
@@ -237,6 +430,154 @@ export const TemplatesOverviewPage = () => {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Диалог копирования */}
<Dialog
open={isCopyDialogOpen}
onOpenChange={setIsCopyDialogOpen}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Копировать шаблон</DialogTitle>
<DialogDescription>
Укажите название и описание для копии шаблона
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="copy-name" className="text-right">
Название
</Label>
<Input
id="copy-name"
className="col-span-3"
value={copyName}
onChange={e => setCopyName(e.target.value)}
placeholder="Введите название"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label
htmlFor="copy-description"
className="text-right"
>
Описание
</Label>
<Textarea
id="copy-description"
className="col-span-3"
value={copyDescription}
onChange={e => setCopyDescription(e.target.value)}
placeholder="Введите описание"
/>
</div>
<div className="grid grid-cols-4 gap-4">
<Label className="self-start pt-2 text-right">
Атрибуты
</Label>
<div className="col-span-3">
{!attributesLoading && (
<CategoryFieldsSelector
selectedValues={toCategoryAttributeValues(
copyAttributeValues
)}
onValuesChange={values =>
setCopyAttributeValues(
fromCategoryAttributeValues(values)
)
}
/>
)}
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Отмена</Button>
</DialogClose>
<Button
onClick={handleCopyConfirm}
disabled={!copyName.trim()}
>
Копировать
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Диалог редактирования */}
<Dialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Редактировать шаблон</DialogTitle>
<DialogDescription>
Измените название и описание шаблона
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-name" className="text-right">
Название
</Label>
<Input
id="edit-name"
className="col-span-3"
value={editName}
onChange={e => setEditName(e.target.value)}
placeholder="Введите название"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label
htmlFor="edit-description"
className="text-right"
>
Описание
</Label>
<Textarea
id="edit-description"
className="col-span-3"
value={editDescription}
onChange={e => setEditDescription(e.target.value)}
placeholder="Введите описание"
/>
</div>
<div className="grid grid-cols-4 gap-4">
<Label className="self-start pt-2 text-right">
Атрибуты
</Label>
<div className="col-span-3">
{!attributesLoading && (
<CategoryFieldsSelector
selectedValues={toCategoryAttributeValues(
editAttributeValues
)}
onValuesChange={values =>
setEditAttributeValues(
fromCategoryAttributeValues(values)
)
}
/>
)}
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Отмена</Button>
</DialogClose>
<Button
onClick={handleEditConfirm}
disabled={!editName.trim()}
>
Сохранить
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</div>
@@ -274,6 +615,10 @@ export const TemplatesOverviewPage = () => {
selectionMode={selectionMode}
isSelected={selected.has(t.id)}
onToggleSelect={toggleSelect}
onCopy={handleCopy}
onEdit={handleEdit}
onDelete={handleDelete}
activeFilters={filters.attributes}
/>
))}
</section>

View File

@@ -1,25 +1,53 @@
import { Button } from '@/component/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
import { Popover, PopoverContent, PopoverTrigger } from '@/component/ui/popover'
import { Template } from '@/type/template'
import { Template, TemplateAttributeDetail } from '@/type/template'
import clsx from 'clsx'
import {
Award,
Check,
Cog,
Copy,
Cpu,
FileText,
MoreVertical,
Pencil,
Settings,
Trash2,
Wrench,
} from 'lucide-react'
import React from 'react'
import { useNavigate } from 'react-router-dom'
interface TemplateCardProps {
template: Template
template: Template & { attributes?: TemplateAttributeDetail[] }
selectionMode?: boolean
isSelected: boolean
onToggleSelect?: (id: string) => void
onDelete?: () => void
onDelete?: (id: string) => void
onCopy?: (id: string) => void
onEdit?: (id: string) => void
activeFilters?: { [attributeId: string]: string } // Добавляем пропс для активных фильтров
}
// Мапинг атрибутов на иконки и определение основных атрибутов
const getAttributeIcon = (attributeName: string) => {
const name = attributeName.toLowerCase()
if (name.includes('тип') || name.includes('прибор')) return Cpu
if (name.includes('госреестр') || name.includes('реестр')) return Award
if (name.includes('исполнение')) return Cog
return null
}
const isPrimaryAttribute = (attributeName: string) => {
const name = attributeName.toLowerCase()
return (
name.includes('тип') ||
name.includes('прибор') ||
name.includes('госреестр') ||
name.includes('реестр') ||
name.includes('исполнение')
)
}
const TemplateCard: React.FC<TemplateCardProps> = ({
@@ -28,11 +56,55 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
isSelected,
onToggleSelect,
onDelete,
onCopy,
onEdit,
activeFilters = {},
}) => {
const navigate = useNavigate()
const isConfigured = true // TODO: remove
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation()
onEdit?.(template.id)
}
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation()
onCopy?.(template.id)
}
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation()
onDelete?.(template.id)
}
// Определяем какие атрибуты показывать
const getDisplayAttributes = () => {
if (!template.attributes)
return { primaryAttributes: [], filteredAttributes: [] }
const attributesWithValues = template.attributes.filter(
attr => attr.value && attr.value.trim()
)
// Находим основные атрибуты (с иконками) независимо от порядка
const primaryAttributes = attributesWithValues.filter(attr =>
isPrimaryAttribute(attr.attribute_name)
)
// Добавляем отфильтрованные атрибуты если они не являются основными
const filteredAttributes = attributesWithValues.filter(
attr =>
activeFilters[attr.attribute_id] &&
!isPrimaryAttribute(attr.attribute_name)
)
return { primaryAttributes, filteredAttributes }
}
const { primaryAttributes, filteredAttributes } = getDisplayAttributes()
return (
// console.log(template.excelFile),
<Card
@@ -43,13 +115,15 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
: undefined
}
className={clsx(
'relative', // <-- добавьте этот класс!
'border-2 ring-4 ring-offset-2',
'group relative transition-all duration-200 ease-in-out',
'border-2 ring-2 ring-offset-1',
selectionMode && 'hover:cursor-pointer',
selectionMode && isSelected
? 'border-primary ring-primary/30'
? 'border-primary bg-primary/5 shadow-md shadow-primary/20 ring-primary/30'
: 'border-border ring-transparent',
'hover:border-primary'
!selectionMode &&
'hover:border-primary/50 hover:shadow-lg hover:shadow-primary/5',
'bg-card hover:bg-card/80'
)}
>
{selectionMode && (
@@ -63,12 +137,14 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
onToggleSelect?.(template.id)
}}
className={clsx(
'absolute right-3 top-3 z-10 flex h-7 w-7 items-center justify-center rounded-full border border-border bg-white transition-all',
isSelected ? 'text-accent-foreground' : 'text-muted-foreground',
'focus-visible:ring-2 focus-visible:ring-accent'
'absolute right-2 top-2 z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 bg-background transition-all',
isSelected
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background text-muted-foreground hover:border-primary',
'focus-visible:ring-2 focus-visible:ring-primary'
)}
>
{isSelected ? <Check size={18} /> : null}
{isSelected ? <Check size={14} /> : null}
</button>
)}
{/* Popover-меню с действиями, только если не selectionMode */}
@@ -80,49 +156,131 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
tabIndex={0}
aria-label="Действия"
className={clsx(
'absolute right-3 top-3 z-10 flex h-7 w-7 items-center justify-center rounded-full border border-border bg-white text-muted-foreground transition-all',
'hover:text-accent-foreground',
'focus-visible:ring-2 focus-visible:ring-accent'
'absolute right-2 top-2 z-10 flex h-6 w-6 items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-all',
'hover:border-primary hover:text-accent-foreground',
'focus-visible:ring-2 focus-visible:ring-primary'
)}
onClick={e => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
<MoreVertical className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-1">
<PopoverContent align="end" className="w-48 p-1">
<button
type="button"
onClick={handleEdit}
className="flex w-full items-center gap-2 rounded px-2 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Pencil className="h-4 w-4" />
Редактировать
</button>
{/* Здесь можно добавить другие действия */}
<button
type="button"
onClick={handleCopy}
className="flex w-full items-center gap-2 rounded px-2 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Copy className="h-4 w-4" />
Копировать
</button>
<div className="mx-1 my-1 h-px bg-border" />
<button
type="button"
onClick={handleDelete}
className="flex w-full items-center gap-2 rounded px-2 py-2 text-sm text-destructive transition-colors hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="h-4 w-4" />
Удалить
</button>
</PopoverContent>
</Popover>
)}
<CardHeader className="pb-4">
<CardTitle className="text-base font-medium">{template.name}</CardTitle>
<CardHeader className="pb-2">
<CardTitle className="pr-8 text-base font-medium leading-tight">
{template.name}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col justify-between pt-0">
<div>
{template.description && (
<p className="mb-3 line-clamp-2 text-sm text-muted-foreground">
<CardContent className="flex flex-col pt-0">
<div className="flex-1">
{/* Описание или пустое место для выравнивания */}
<div className="mb-3">
{template.description ? (
<p className="line-clamp-1 text-sm leading-tight text-muted-foreground">
{template.description}
</p>
) : (
<p className="text-sm italic leading-tight text-muted-foreground/40">
Описание не указано
</p>
)}
</div>
{/* Атрибуты */}
{(primaryAttributes.length > 0 || filteredAttributes.length > 0) && (
<div className="mb-3">
<div className="space-y-1">
{/* Основные атрибуты с иконками */}
{primaryAttributes.map(attr => {
const Icon = getAttributeIcon(attr.attribute_name)
const isFiltered = activeFilters[attr.attribute_id]
return (
<div
key={attr.attribute_id}
className={clsx(
'flex items-center justify-between rounded px-2 py-0.5 text-xs',
isFiltered
? 'border border-primary/30 bg-primary/20'
: 'bg-muted/30'
)}
>
<div className="flex min-w-0 flex-1 items-center">
{Icon && (
<Icon className="mr-1.5 h-3 w-3 flex-shrink-0 text-muted-foreground" />
)}
<span className="truncate font-medium text-foreground/80">
{attr.attribute_name}
</span>
</div>
<span className="ml-2 max-w-[40%] truncate text-muted-foreground">
{attr.value}
</span>
</div>
)
})}
{/* Отфильтрованные атрибуты (если не входят в основные) */}
{filteredAttributes.map(attr => (
<div
key={attr.attribute_id}
className="flex items-center justify-between rounded border border-primary/30 bg-primary/20 px-2 py-0.5 text-xs"
>
<span className="truncate font-medium text-foreground/80">
{attr.attribute_name}
</span>
<span className="ml-2 max-w-[60%] truncate text-muted-foreground">
{attr.value}
</span>
</div>
))}
</div>
</div>
)}
<div className="mb-3 flex justify-between text-xs text-muted-foreground/80">
{/* Даты */}
<div className="mb-3 grid grid-cols-2 gap-2 text-xs text-muted-foreground/70">
<div className="flex flex-col gap-0.5">
<span className="font-medium">Создан</span>
<span>
Создан:{' '}
{new Date(template.createdAt).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
})}
</span>
</div>
<div className="flex flex-col gap-0.5 text-right">
<span className="font-medium">Обновлён</span>
<span>
Обновлён:{' '}
{new Date(template.updatedAt).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
@@ -131,24 +289,27 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
</span>
</div>
</div>
<div className="mt-2 flex items-center gap-2">
</div>
{/* Кнопки действий */}
<div className="flex items-center gap-1.5 border-t border-border/50 pt-2">
<Button
size="sm"
variant="outline"
className="h-9 flex-1"
className="h-7 flex-1 text-xs"
disabled={selectionMode || !isConfigured}
onClick={e => {
e.stopPropagation()
navigate(`/templates/${template.id}/protocols`)
}}
>
<FileText className="mr-2 h-4 w-4" />
<FileText className="mr-1 h-3 w-3" />
{isConfigured ? 'Создать' : 'Требует настройки'}
</Button>
<Button
size="sm"
variant="outline"
className="h-9 w-9 p-0"
className="h-7 w-7 p-0"
disabled={selectionMode}
onClick={e => {
e.stopPropagation()
@@ -156,12 +317,12 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
}}
aria-label="Редактировать шаблон"
>
<Settings className="h-4 w-4" />
<Settings className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="outline"
className="h-9 w-9 p-0"
className="h-7 w-7 p-0"
disabled={selectionMode}
onClick={e => {
e.stopPropagation()
@@ -169,7 +330,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
}}
aria-label="Настроить элементы"
>
<Wrench className="h-4 w-4" />
<Wrench className="h-3 w-3" />
</Button>
</div>
</CardContent>