фильтры

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

View File

@@ -9,7 +9,7 @@ export interface CreateTemplateOutput {
} }
export interface GetTemplatesOutput { export interface GetTemplatesOutput {
templates: ApiTemplate[] templates: ApiTemplateWithAttributes[]
} }
export interface GetTemplateOutput { export interface GetTemplateOutput {
@@ -24,6 +24,10 @@ export interface PatchTemplateInput {
name?: string | null name?: string | null
description?: string | null description?: string | null
elements?: Record<string, any> | null elements?: Record<string, any> | null
attributes?: Array<{
attribute_id: string
value: string | null
}> | null
} }
export interface PatchTemplateOutput { export interface PatchTemplateOutput {
@@ -126,6 +130,7 @@ export function templateToPatchInput(template: Template): PatchTemplateInput {
name: template.name, name: template.name,
description: template.description, description: template.description,
elements, elements,
// attributes будут добавлены отдельно в TemplateContext при необходимости
} }
} }
@@ -178,7 +183,7 @@ export async function getTemplatesApi(): Promise<GetTemplatesOutput> {
// API получение конкретного шаблона // API получение конкретного шаблона
export async function getTemplateApi( export async function getTemplateApi(
templateId: UUID templateId: string
): Promise<GetTemplateOutput> { ): Promise<GetTemplateOutput> {
try { try {
const response = await fetch(`/api/templates/${templateId}`, { const response = await fetch(`/api/templates/${templateId}`, {
@@ -202,18 +207,15 @@ export async function getTemplateApi(
// API получение шаблона с атрибутами // API получение шаблона с атрибутами
export async function getTemplateWithAttributesApi( export async function getTemplateWithAttributesApi(
templateId: UUID templateId: string
): Promise<GetTemplateWithAttributesOutput> { ): Promise<GetTemplateWithAttributesOutput> {
try { try {
const response = await fetch( const response = await fetch(`/api/templates/${templateId}`, {
`/api/templates/${templateId}/with-attributes`,
{
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
} })
)
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`) throw new Error(`HTTP error! status: ${response.status}`)
@@ -260,3 +262,73 @@ export async function updateTemplate(
const patchInput = templateToPatchInput(template) const patchInput = templateToPatchInput(template)
return patchTemplateApi(template.id, patchInput) 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 { import {
apiTemplateToTemplate, apiTemplateToTemplate,
apiTemplateWithAttributesToTemplate,
copyTemplateApi,
CopyTemplateInput,
createTemplateApi, createTemplateApi,
deleteTemplateApi,
getTemplateApi, getTemplateApi,
getTemplatesApi, getTemplatesApi,
patchTemplateApi, patchTemplateApi,
@@ -26,8 +30,17 @@ interface TemplateContextType {
addTemplate: ( addTemplate: (
data: Omit<Template, 'id' | 'createdAt' | 'updatedAt'> data: Omit<Template, 'id' | 'createdAt' | 'updatedAt'>
) => Promise<Template> ) => Promise<Template>
updateTemplate: (template: Template) => Promise<Template> updateTemplate: (
deleteTemplate: (id: UUID) => Promise<string> 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>( const TemplateContext = createContext<TemplateContextType | undefined>(
@@ -48,7 +61,7 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
queryKey: ['templates'], queryKey: ['templates'],
queryFn: async () => { queryFn: async () => {
const { templates: apiList } = await getTemplatesApi() 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({ const updateMutation = useMutation({
mutationFn: async (template: Template) => { mutationFn: async ({
template,
attributes,
}: {
template: Template
attributes?: Array<{ attributeId: string; value: string | null }>
}) => {
const input = templateToPatchInput(template) const input = templateToPatchInput(template)
if (attributes) {
input.attributes = attributes.map(attr => ({
attribute_id: attr.attributeId,
value: attr.value,
}))
}
await patchTemplateApi(template.id, input) await patchTemplateApi(template.id, input)
return template return template
}, },
@@ -84,7 +109,10 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: async (id: string) => { mutationFn: async (id: string) => {
// TODO: deleteTemplateApi const result = await deleteTemplateApi(id)
if (!result.success) {
throw new Error('Не удалось удалить шаблон')
}
return id return id
}, },
onSuccess: 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( const value = useMemo(
() => ({ () => ({
templates, templates,
@@ -102,17 +162,38 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
listStatus === 'pending' || listStatus === 'pending' ||
createMutation.status === 'pending' || createMutation.status === 'pending' ||
updateMutation.status === 'pending' || updateMutation.status === 'pending' ||
deleteMutation.status === 'pending', deleteMutation.status === 'pending' ||
copyMutation.status === 'pending',
error: error:
(errorList as Error) || (errorList as Error) ||
(createMutation.error as Error) || (createMutation.error as Error) ||
(updateMutation.error as Error) || (updateMutation.error as Error) ||
(deleteMutation.error as Error) || (deleteMutation.error as Error) ||
(copyMutation.error as Error) ||
null, null,
refetchTemplates: refetch, refetchTemplates: refetch,
addTemplate: createMutation.mutateAsync, addTemplate: createMutation.mutateAsync,
updateTemplate: updateMutation.mutateAsync, updateTemplate: async (
template: Template,
attributes?: Array<{ attributeId: string; value: string | null }>
) =>
updateMutation.mutateAsync({
template,
attributes,
}),
deleteTemplate: deleteMutation.mutateAsync, 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, templates,
@@ -124,10 +205,13 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
updateMutation.error, updateMutation.error,
deleteMutation.status, deleteMutation.status,
deleteMutation.error, deleteMutation.error,
copyMutation.status,
copyMutation.error,
refetch, refetch,
createMutation.mutateAsync, createMutation.mutateAsync,
updateMutation.mutateAsync, updateMutation.mutateAsync,
deleteMutation.mutateAsync, deleteMutation.mutateAsync,
copyMutation.mutateAsync,
] ]
) )

View File

@@ -23,30 +23,82 @@ import {
SidebarTrigger, SidebarTrigger,
} from '@/component/ui/sidebar' } from '@/component/ui/sidebar'
import { Textarea } from '@/component/ui/textarea' import { Textarea } from '@/component/ui/textarea'
import {
apiTemplateWithAttributesToTemplate,
getTemplateWithAttributesApi,
} from '@/entitiy/template/api/templateApiService'
import { useCategoryContext } from '@/entitiy/template/model/CategoryContext' import { useCategoryContext } from '@/entitiy/template/model/CategoryContext'
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext' import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
import { Template, TemplateAttributeDetail } from '@/type/template'
import TemplateCard from '@/widget/template/ui/TemplateCard' import TemplateCard from '@/widget/template/ui/TemplateCard'
import clsx from 'clsx' import clsx from 'clsx'
import { CheckSquare, Plus, Square, Trash2 } from 'lucide-react' import { CheckSquare, Plus, Square, Trash2 } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
interface AttributeValue { interface AttributeValue {
attributeId: string
value: string | null
}
// Тип для CategoryFieldsSelector
interface CategoryAttributeValue {
attributeId: string attributeId: string
value: string | number 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 = () => { export const TemplatesOverviewPage = () => {
const { templates, deleteTemplate, addTemplate } = useTemplateContext() const {
templates,
deleteTemplate,
addTemplate,
copyTemplate,
updateTemplate,
} = useTemplateContext()
const { isLoading: attributesLoading } = useCategoryContext() const { isLoading: attributesLoading } = useCategoryContext()
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const [selectionMode, setSelectionMode] = useState(false) const [selectionMode, setSelectionMode] = useState(false)
const [isDialogOpen, setIsDialogOpen] = 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 [newName, setNewName] = useState('')
const [newDescription, setNewDescription] = useState('') const [newDescription, setNewDescription] = useState('')
const [newAttributeValues, setNewAttributeValues] = useState< const [newAttributeValues, setNewAttributeValues] = useState<
AttributeValue[] 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>({ const [filters, setFilters] = useState<TemplateFilters>({
name: '', name: '',
description: '', 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 = () => { const removeSelected = () => {
selected.forEach(deleteTemplate) selected.forEach(deleteTemplate)
setSelected(new Set()) 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 = () => { const toggleSelectionMode = () => {
setSelectionMode(v => { setSelectionMode(v => {
if (v) setSelected(new Set()) if (v) setSelected(new Set())
@@ -111,9 +267,40 @@ export const TemplatesOverviewPage = () => {
return false return false
} }
// Примечание: Фильтрация по атрибутам пока отключена, // Фильтрация по атрибутам
// так как атрибуты теперь хранятся отдельно от шаблонов if (Object.keys(filters.attributes).length > 0) {
// и требуют отдельного запроса к API для каждого шаблона // Проверяем есть ли атрибуты у шаблона
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 return true
}) })
@@ -222,8 +409,14 @@ export const TemplatesOverviewPage = () => {
<div className="col-span-3"> <div className="col-span-3">
{!attributesLoading && ( {!attributesLoading && (
<CategoryFieldsSelector <CategoryFieldsSelector
selectedValues={newAttributeValues} selectedValues={toCategoryAttributeValues(
onValuesChange={setNewAttributeValues} newAttributeValues
)}
onValuesChange={values =>
setNewAttributeValues(
fromCategoryAttributeValues(values)
)
}
/> />
)} )}
</div> </div>
@@ -237,6 +430,154 @@ export const TemplatesOverviewPage = () => {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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> </div>
@@ -274,6 +615,10 @@ export const TemplatesOverviewPage = () => {
selectionMode={selectionMode} selectionMode={selectionMode}
isSelected={selected.has(t.id)} isSelected={selected.has(t.id)}
onToggleSelect={toggleSelect} onToggleSelect={toggleSelect}
onCopy={handleCopy}
onEdit={handleEdit}
onDelete={handleDelete}
activeFilters={filters.attributes}
/> />
))} ))}
</section> </section>

View File

@@ -1,25 +1,53 @@
import { Button } from '@/component/ui/button' import { Button } from '@/component/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
import { Popover, PopoverContent, PopoverTrigger } from '@/component/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/component/ui/popover'
import { Template } from '@/type/template' import { Template, TemplateAttributeDetail } from '@/type/template'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
Award,
Check, Check,
Cog,
Copy,
Cpu,
FileText, FileText,
MoreVertical, MoreVertical,
Pencil, Pencil,
Settings, Settings,
Trash2,
Wrench, Wrench,
} from 'lucide-react' } from 'lucide-react'
import React from 'react' import React from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
interface TemplateCardProps { interface TemplateCardProps {
template: Template template: Template & { attributes?: TemplateAttributeDetail[] }
selectionMode?: boolean selectionMode?: boolean
isSelected: boolean isSelected: boolean
onToggleSelect?: (id: string) => void 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> = ({ const TemplateCard: React.FC<TemplateCardProps> = ({
@@ -28,11 +56,55 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
isSelected, isSelected,
onToggleSelect, onToggleSelect,
onDelete, onDelete,
onCopy,
onEdit,
activeFilters = {},
}) => { }) => {
const navigate = useNavigate() const navigate = useNavigate()
const isConfigured = true // TODO: remove 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 ( return (
// console.log(template.excelFile), // console.log(template.excelFile),
<Card <Card
@@ -43,13 +115,15 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
: undefined : undefined
} }
className={clsx( className={clsx(
'relative', // <-- добавьте этот класс! 'group relative transition-all duration-200 ease-in-out',
'border-2 ring-4 ring-offset-2', 'border-2 ring-2 ring-offset-1',
selectionMode && 'hover:cursor-pointer', selectionMode && 'hover:cursor-pointer',
selectionMode && isSelected selectionMode && isSelected
? 'border-primary ring-primary/30' ? 'border-primary bg-primary/5 shadow-md shadow-primary/20 ring-primary/30'
: 'border-border ring-transparent', : '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 && ( {selectionMode && (
@@ -63,12 +137,14 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
onToggleSelect?.(template.id) onToggleSelect?.(template.id)
}} }}
className={clsx( 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', '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 ? 'text-accent-foreground' : 'text-muted-foreground', isSelected
'focus-visible:ring-2 focus-visible:ring-accent' ? '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> </button>
)} )}
{/* Popover-меню с действиями, только если не selectionMode */} {/* Popover-меню с действиями, только если не selectionMode */}
@@ -80,49 +156,131 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
tabIndex={0} tabIndex={0}
aria-label="Действия" aria-label="Действия"
className={clsx( 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', '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:text-accent-foreground', 'hover:border-primary hover:text-accent-foreground',
'focus-visible:ring-2 focus-visible:ring-accent' '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> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" className="w-40 p-1"> <PopoverContent align="end" className="w-48 p-1">
<button <button
type="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" 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" /> <Pencil className="h-4 w-4" />
Редактировать Редактировать
</button> </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> </PopoverContent>
</Popover> </Popover>
)} )}
<CardHeader className="pb-4"> <CardHeader className="pb-2">
<CardTitle className="text-base font-medium">{template.name}</CardTitle> <CardTitle className="pr-8 text-base font-medium leading-tight">
{template.name}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col justify-between pt-0"> <CardContent className="flex flex-col pt-0">
<div> <div className="flex-1">
{template.description && ( {/* Описание или пустое место для выравнивания */}
<p className="mb-3 line-clamp-2 text-sm text-muted-foreground"> <div className="mb-3">
{template.description ? (
<p className="line-clamp-1 text-sm leading-tight text-muted-foreground">
{template.description} {template.description}
</p> </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> <span>
Создан:{' '}
{new Date(template.createdAt).toLocaleDateString('ru-RU', { {new Date(template.createdAt).toLocaleDateString('ru-RU', {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
year: '2-digit', year: '2-digit',
})} })}
</span> </span>
</div>
<div className="flex flex-col gap-0.5 text-right">
<span className="font-medium">Обновлён</span>
<span> <span>
Обновлён:{' '}
{new Date(template.updatedAt).toLocaleDateString('ru-RU', { {new Date(template.updatedAt).toLocaleDateString('ru-RU', {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
@@ -131,24 +289,27 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
</span> </span>
</div> </div>
</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 <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-9 flex-1" className="h-7 flex-1 text-xs"
disabled={selectionMode || !isConfigured} disabled={selectionMode || !isConfigured}
onClick={e => { onClick={e => {
e.stopPropagation() e.stopPropagation()
navigate(`/templates/${template.id}/protocols`) navigate(`/templates/${template.id}/protocols`)
}} }}
> >
<FileText className="mr-2 h-4 w-4" /> <FileText className="mr-1 h-3 w-3" />
{isConfigured ? 'Создать' : 'Требует настройки'} {isConfigured ? 'Создать' : 'Требует настройки'}
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-9 w-9 p-0" className="h-7 w-7 p-0"
disabled={selectionMode} disabled={selectionMode}
onClick={e => { onClick={e => {
e.stopPropagation() e.stopPropagation()
@@ -156,12 +317,12 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
}} }}
aria-label="Редактировать шаблон" aria-label="Редактировать шаблон"
> >
<Settings className="h-4 w-4" /> <Settings className="h-3 w-3" />
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-9 w-9 p-0" className="h-7 w-7 p-0"
disabled={selectionMode} disabled={selectionMode}
onClick={e => { onClick={e => {
e.stopPropagation() e.stopPropagation()
@@ -169,7 +330,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
}} }}
aria-label="Настроить элементы" aria-label="Настроить элементы"
> >
<Wrench className="h-4 w-4" /> <Wrench className="h-3 w-3" />
</Button> </Button>
</div> </div>
</CardContent> </CardContent>