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