фильтры
This commit is contained in:
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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('Ошибка при удалении шаблона на сервере')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user