Единый интерфейс элементов
This commit is contained in:
225
src/entitiy/element/model/implementations/ButtonGroup.tsx
Normal file
225
src/entitiy/element/model/implementations/ButtonGroup.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { Button } from '@/component/ui/button'
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||
import { ElementOption } from '@/type/template'
|
||||
import { Plus, Square, Trash2 } from 'lucide-react'
|
||||
|
||||
interface ButtonGroupConfig {
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
options: ElementOption[]
|
||||
targetCells: any[]
|
||||
layout?: 'horizontal' | 'vertical'
|
||||
buttonStyle?: 'default' | 'outline' | 'ghost'
|
||||
name?: string
|
||||
}
|
||||
|
||||
export const buttonGroupDefinition: ElementDefinition<
|
||||
ButtonGroupConfig,
|
||||
string
|
||||
> = {
|
||||
type: 'button_group',
|
||||
label: 'Группа кнопок',
|
||||
icon: <Square className="h-4 w-4" />,
|
||||
version: 1,
|
||||
defaultConfig: {
|
||||
placeholder: '',
|
||||
required: false,
|
||||
options: [],
|
||||
targetCells: [],
|
||||
layout: 'horizontal',
|
||||
buttonStyle: 'default',
|
||||
name: '',
|
||||
},
|
||||
// Берём целевые ячейки прямо из конфига, если они заданы в редакторе
|
||||
mapToCells: cfg => cfg.targetCells || [],
|
||||
Editor: ({ config, onChange }) => {
|
||||
const handleAddOption = () => {
|
||||
onChange({
|
||||
...config,
|
||||
options: [...config.options, { value: '', label: '' }],
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateOption = (
|
||||
index: number,
|
||||
field: 'value' | 'label',
|
||||
value: string
|
||||
) => {
|
||||
onChange({
|
||||
...config,
|
||||
options: config.options.map((option, i) =>
|
||||
i === index ? { ...option, [field]: value } : option
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveOption = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
options: config.options.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* <div className="space-y-2">
|
||||
<label className="text-sm font-medium">Расположение кнопок</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={config.layout === 'horizontal' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onChange({ ...config, layout: 'horizontal' })}
|
||||
>
|
||||
Горизонтально
|
||||
</Button>
|
||||
<Button
|
||||
variant={config.layout === 'vertical' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onChange({ ...config, layout: 'vertical' })}
|
||||
>
|
||||
Вертикально
|
||||
</Button>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Стиль кнопок</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={config.buttonStyle === 'default' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onChange({ ...config, buttonStyle: 'default' })}
|
||||
>
|
||||
Обычный
|
||||
</Button>
|
||||
<Button
|
||||
variant={config.buttonStyle === 'outline' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onChange({ ...config, buttonStyle: 'outline' })}
|
||||
>
|
||||
Контурный
|
||||
</Button>
|
||||
<Button
|
||||
variant={config.buttonStyle === 'ghost' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onChange({ ...config, buttonStyle: 'ghost' })}
|
||||
>
|
||||
Призрачный
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Варианты кнопок</label>
|
||||
<Button variant="outline" size="sm" onClick={handleAddOption}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Добавить кнопку
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-32 space-y-2 overflow-y-auto">
|
||||
{config.options.map((option, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Значение"
|
||||
value={option.value}
|
||||
onChange={e =>
|
||||
handleUpdateOption(index, 'value', e.target.value)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Текст кнопки"
|
||||
value={option.label}
|
||||
onChange={e =>
|
||||
handleUpdateOption(index, 'label', e.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveOption(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
Preview: ({ config }) => (
|
||||
<div className="space-y-3">
|
||||
{config.name && (
|
||||
<label className="text-sm font-medium text-gray-900">
|
||||
{config.name}
|
||||
{config.required && <span className="ml-1 text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div
|
||||
className={`flex ${
|
||||
config.layout === 'vertical' ? 'flex-col' : 'flex-wrap'
|
||||
} gap-1.5`}
|
||||
>
|
||||
{config.options.length > 0 ? (
|
||||
config.options.map((option, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm font-medium transition-all duration-200 ${
|
||||
config.buttonStyle === 'outline'
|
||||
? 'border-border bg-background text-foreground hover:border-primary/30 hover:bg-primary/5'
|
||||
: config.buttonStyle === 'ghost'
|
||||
? 'border-transparent bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
: 'border-primary bg-primary text-primary-foreground shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{option.label || `Кнопка ${index + 1}`}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 px-3 py-1.5 text-sm text-muted-foreground">
|
||||
{config.placeholder || 'Добавьте кнопки'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Render: ({ config, value, onChange }) => (
|
||||
<div className="space-y-3">
|
||||
{config.name && (
|
||||
<label className="text-sm font-medium text-gray-900">
|
||||
{config.name}
|
||||
{config.required && <span className="ml-1 text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div
|
||||
className={`flex ${
|
||||
config.layout === 'vertical' ? 'flex-col' : 'flex-wrap'
|
||||
} gap-1.5`}
|
||||
>
|
||||
{config.options.map((option, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onChange?.(option.value)}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm font-medium transition-all duration-200 ${
|
||||
value === option.value
|
||||
? 'border-primary bg-primary text-primary-foreground shadow-sm'
|
||||
: config.buttonStyle === 'outline'
|
||||
? 'border-border bg-background text-foreground hover:border-primary/30 hover:bg-primary/5'
|
||||
: config.buttonStyle === 'ghost'
|
||||
? 'border-transparent bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
: 'border-primary bg-primary text-primary-foreground shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{config.required && !value && (
|
||||
<p className="text-sm text-red-500">
|
||||
Это поле обязательно для заполнения
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
53
src/entitiy/element/model/implementations/TextElement.tsx
Normal file
53
src/entitiy/element/model/implementations/TextElement.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||
import { ExtraSettingsBadge } from '@/entitiy/element/ui/extraSettingsBadge'
|
||||
import { Type } from 'lucide-react'
|
||||
|
||||
interface TextConfig {
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
targetCells: any[]
|
||||
name?: string
|
||||
}
|
||||
|
||||
export const textDefinition: ElementDefinition<TextConfig, string> = {
|
||||
type: 'text',
|
||||
label: 'Текстовое поле',
|
||||
icon: <Type className="h-4 w-4" />,
|
||||
version: 1,
|
||||
defaultConfig: {
|
||||
placeholder: '',
|
||||
required: false,
|
||||
targetCells: [],
|
||||
name: '',
|
||||
},
|
||||
mapToCells: config => config.targetCells || [],
|
||||
Editor: () => <ExtraSettingsBadge />,
|
||||
Preview: ({ config }) => (
|
||||
<div className="space-y-1">
|
||||
{config.name && (
|
||||
<label className="text-sm font-medium text-gray-900">
|
||||
{config.name}
|
||||
{config.required && <span className="ml-1 text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<Input placeholder={config.placeholder} className="w-full" />
|
||||
</div>
|
||||
),
|
||||
Render: ({ config, value, onChange }) => (
|
||||
<div className="space-y-1">
|
||||
{config.name && (
|
||||
<label className="text-sm font-medium text-gray-900">
|
||||
{config.name}
|
||||
{config.required && <span className="ml-1 text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<Input
|
||||
placeholder={config.placeholder}
|
||||
value={value || ''}
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
required={config.required}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
91
src/entitiy/element/model/interface.ts
Normal file
91
src/entitiy/element/model/interface.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { textDefinition } from '@/component/BasicElements'
|
||||
import { calibrationConditionsDefinition } from '@/component/BasicElements/CalibrationConditionsElement'
|
||||
import { checkboxDefinition } from '@/component/BasicElements/CheckboxElement'
|
||||
import { dateDefinition } from '@/component/BasicElements/DateElement'
|
||||
import { numberDefinition } from '@/component/BasicElements/NumberElement'
|
||||
import { radioDefinition } from '@/component/BasicElements/RadioElement'
|
||||
import { selectDefinition } from '@/component/BasicElements/SelectElement'
|
||||
import { textareaDefinition } from '@/component/BasicElements/TextareaElement'
|
||||
import { standardsDefinition } from '@/component/StandardsElement/definition'
|
||||
import { buttonGroupDefinition } from '@/entitiy/element/model/implementations/ButtonGroup'
|
||||
import { CellTarget } from '@/type/template'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface ElementDefinition<Config = any, Value = any> {
|
||||
type: ElementType
|
||||
label: string
|
||||
icon: ReactNode
|
||||
|
||||
/* React-компоненты */
|
||||
Editor: React.FC<{ config: Config; onChange(c: Config): void }>
|
||||
Preview: React.FC<{ config: Config }>
|
||||
Render: React.FC<{ config: Config; value: Value; onChange?(v: Value): void }>
|
||||
|
||||
/* бизнес-логика */
|
||||
defaultConfig: Config
|
||||
mapToCells(config: Config): CellTarget[] // куда пишем данные
|
||||
|
||||
/* мета */
|
||||
version: number
|
||||
}
|
||||
|
||||
export const elementRegistry: Record<ElementType, ElementDefinition> = {} as any
|
||||
|
||||
// Функция для регистрации элемента
|
||||
export function registerElement<T extends ElementType>(
|
||||
type: T,
|
||||
definition: ElementDefinition
|
||||
) {
|
||||
elementRegistry[type] = definition
|
||||
}
|
||||
|
||||
// Получить все доступные типы элементов
|
||||
export function getAvailableElementTypes(): Array<{
|
||||
type: ElementType
|
||||
label: string
|
||||
icon: ReactNode
|
||||
}> {
|
||||
return Object.values(elementRegistry).map(({ type, label, icon }) => ({
|
||||
type,
|
||||
label,
|
||||
icon,
|
||||
}))
|
||||
}
|
||||
|
||||
// Получить определение элемента по типу
|
||||
export function getElementDefinition(
|
||||
type: ElementType
|
||||
): ElementDefinition | undefined {
|
||||
return elementRegistry[type]
|
||||
}
|
||||
|
||||
export const elementDefinitions = {
|
||||
text: textDefinition,
|
||||
select: selectDefinition,
|
||||
radio: radioDefinition,
|
||||
checkbox: checkboxDefinition,
|
||||
number: numberDefinition,
|
||||
textarea: textareaDefinition,
|
||||
date: dateDefinition,
|
||||
standards: standardsDefinition,
|
||||
calibration_conditions: calibrationConditionsDefinition,
|
||||
button_group: buttonGroupDefinition,
|
||||
} as const
|
||||
|
||||
export type ElementType = keyof typeof elementDefinitions
|
||||
|
||||
export function initializeElementRegistry() {
|
||||
// Базовые элементы
|
||||
registerElement('text', textDefinition)
|
||||
registerElement('select', selectDefinition)
|
||||
registerElement('number', numberDefinition)
|
||||
registerElement('date', dateDefinition)
|
||||
registerElement('textarea', textareaDefinition)
|
||||
registerElement('checkbox', checkboxDefinition)
|
||||
registerElement('radio', radioDefinition)
|
||||
registerElement('button_group', buttonGroupDefinition)
|
||||
registerElement('calibration_conditions', calibrationConditionsDefinition)
|
||||
|
||||
// Специальные элементы
|
||||
registerElement('standards', standardsDefinition)
|
||||
}
|
||||
7
src/entitiy/element/ui/extraSettingsBadge.tsx
Normal file
7
src/entitiy/element/ui/extraSettingsBadge.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export const ExtraSettingsBadge = () => {
|
||||
return (
|
||||
<div className="text-sm text-gray-600">
|
||||
Дополнительные настройки отсутствуют
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export interface CreateTemplateInput {
|
||||
}
|
||||
|
||||
export interface CreateTemplateOutput {
|
||||
id: UUID
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface GetTemplatesOutput {
|
||||
@@ -16,6 +16,10 @@ export interface GetTemplateOutput {
|
||||
template: ApiTemplate | null
|
||||
}
|
||||
|
||||
export interface GetTemplateWithAttributesOutput {
|
||||
template: ApiTemplateWithAttributes | null
|
||||
}
|
||||
|
||||
export interface PatchTemplateInput {
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
@@ -36,9 +40,24 @@ export interface ApiTemplate {
|
||||
deleted_at?: string | null
|
||||
}
|
||||
|
||||
export interface ApiTemplateWithAttributes extends ApiTemplate {
|
||||
attributes: Array<{
|
||||
attribute_id: string
|
||||
attribute_name: string
|
||||
is_required: boolean
|
||||
value: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>
|
||||
}
|
||||
|
||||
// Импортируем типы для преобразования
|
||||
import { getDefaultLayoutSettings, migrateTemplateElement } from '@/lib/utils'
|
||||
import { Template, TemplateElement } from '@/type/template'
|
||||
import {
|
||||
Template,
|
||||
TemplateAttributeDetail,
|
||||
TemplateElement,
|
||||
} from '@/type/template'
|
||||
|
||||
// Утилитарные функции для преобразования данных
|
||||
|
||||
@@ -60,6 +79,25 @@ export function apiTemplateToTemplate(apiTemplate: ApiTemplate): Template {
|
||||
}
|
||||
}
|
||||
|
||||
// Преобразование API шаблона с атрибутами в внутренний формат
|
||||
export function apiTemplateWithAttributesToTemplate(
|
||||
apiTemplate: ApiTemplateWithAttributes
|
||||
): Template & { attributes: TemplateAttributeDetail[] } {
|
||||
const baseTemplate = apiTemplateToTemplate(apiTemplate)
|
||||
|
||||
return {
|
||||
...baseTemplate,
|
||||
attributes: apiTemplate.attributes.map(attr => ({
|
||||
attribute_id: attr.attribute_id,
|
||||
attribute_name: attr.attribute_name,
|
||||
is_required: attr.is_required,
|
||||
value: attr.value,
|
||||
created_at: attr.created_at,
|
||||
updated_at: attr.updated_at,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// Преобразование внутреннего шаблона в API формат для создания
|
||||
export function templateToCreateInput(
|
||||
template: Omit<Template, 'id' | 'createdAt' | 'updatedAt'>
|
||||
@@ -79,9 +117,8 @@ export function templateToCreateInput(
|
||||
|
||||
// Преобразование внутреннего шаблона в API формат для обновления
|
||||
export function templateToPatchInput(template: Template): PatchTemplateInput {
|
||||
// Преобразуем элементы в Record<string, any>
|
||||
const elements: Record<string, any> = {}
|
||||
template.elements.forEach((element, index) => {
|
||||
template.elements.forEach(element => {
|
||||
elements[element.id] = element
|
||||
})
|
||||
|
||||
@@ -163,6 +200,33 @@ export async function getTemplateApi(
|
||||
}
|
||||
}
|
||||
|
||||
// API получение шаблона с атрибутами
|
||||
export async function getTemplateWithAttributesApi(
|
||||
templateId: UUID
|
||||
): Promise<GetTemplateWithAttributesOutput> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/templates/${templateId}/with-attributes`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const result: GetTemplateWithAttributesOutput = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении шаблона с атрибутами:', error)
|
||||
throw new Error('Ошибка при получении шаблона с атрибутами с сервера')
|
||||
}
|
||||
}
|
||||
|
||||
// API обновление шаблона
|
||||
export async function patchTemplateApi(
|
||||
templateId: string,
|
||||
|
||||
289
src/entitiy/template/model/CategoryContext.tsx
Normal file
289
src/entitiy/template/model/CategoryContext.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { Attribute, TemplateAttributeDetail } from '@/type/template'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import React, { createContext, ReactNode, useContext, useMemo } from 'react'
|
||||
|
||||
interface CategoryContextType {
|
||||
attributes: Attribute[]
|
||||
isLoading: boolean
|
||||
error: Error | null
|
||||
refetchAttributes: () => void
|
||||
addAttribute: (data: {
|
||||
name: string
|
||||
is_required: boolean
|
||||
}) => Promise<Attribute>
|
||||
updateAttribute: (attribute: Attribute) => Promise<Attribute>
|
||||
deleteAttribute: (id: string) => Promise<string>
|
||||
// Утилитарные функции для работы с атрибутами
|
||||
getRequiredAttributes: () => Attribute[]
|
||||
getOptionalAttributes: () => Attribute[]
|
||||
getAttributeById: (id: string) => Attribute | undefined
|
||||
}
|
||||
|
||||
interface TemplateAttributesContextType {
|
||||
getTemplateAttributes: (
|
||||
templateId: string
|
||||
) => Promise<TemplateAttributeDetail[]>
|
||||
setTemplateAttributes: (
|
||||
templateId: string,
|
||||
attributes: Array<{ attribute_id: string; value: string | null }>
|
||||
) => Promise<
|
||||
Array<{ template_id: string; attribute_id: string; value: string | null }>
|
||||
>
|
||||
}
|
||||
|
||||
const CategoryContext = createContext<CategoryContextType | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const TemplateAttributesContext = createContext<
|
||||
TemplateAttributesContextType | undefined
|
||||
>(undefined)
|
||||
|
||||
// API функции для работы с атрибутами
|
||||
const fetchAttributes = async (): Promise<Attribute[]> => {
|
||||
const response = await fetch('/api/attributes/')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.attributes || []
|
||||
}
|
||||
|
||||
const createAttribute = async (attribute: {
|
||||
name: string
|
||||
is_required: boolean
|
||||
}): Promise<Attribute> => {
|
||||
const response = await fetch('/api/attributes/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(attribute),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const result = await response.json()
|
||||
|
||||
// После создания получаем полную информацию об атрибуте
|
||||
// так как API возвращает только id
|
||||
const attributesResponse = await fetch('/api/attributes/')
|
||||
if (!attributesResponse.ok) {
|
||||
throw new Error(`HTTP error! status: ${attributesResponse.status}`)
|
||||
}
|
||||
const attributesData = await attributesResponse.json()
|
||||
const newAttribute = attributesData.attributes.find(
|
||||
(attr: Attribute) => attr.id === result.id
|
||||
)
|
||||
|
||||
if (!newAttribute) {
|
||||
throw new Error('Created attribute not found')
|
||||
}
|
||||
|
||||
return newAttribute
|
||||
}
|
||||
|
||||
const updateAttribute = async (attribute: Attribute): Promise<Attribute> => {
|
||||
// Поскольку в API нет эндпоинта для обновления атрибутов,
|
||||
// возвращаем переданный атрибут как есть
|
||||
// TODO: Добавить PATCH /attributes/{id} в API
|
||||
return attribute
|
||||
}
|
||||
|
||||
const deleteAttribute = async (id: string): Promise<string> => {
|
||||
// Поскольку в API нет эндпоинта для удаления атрибутов,
|
||||
// просто возвращаем id
|
||||
// TODO: Добавить DELETE /attributes/{id} в API
|
||||
return id
|
||||
}
|
||||
|
||||
// API функции для работы с атрибутами шаблонов
|
||||
const fetchTemplateAttributes = async (
|
||||
templateId: string
|
||||
): Promise<TemplateAttributeDetail[]> => {
|
||||
const response = await fetch(`/api/templates/${templateId}/attributes`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.attributes || []
|
||||
}
|
||||
|
||||
const setTemplateAttributes = async (
|
||||
templateId: string,
|
||||
attributes: Array<{ attribute_id: string; value: string | null }>
|
||||
): Promise<
|
||||
Array<{ template_id: string; attribute_id: string; value: string | null }>
|
||||
> => {
|
||||
const response = await fetch(`/api/templates/${templateId}/attributes`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ attributes }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const CategoryProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const client = useQueryClient()
|
||||
|
||||
const {
|
||||
data: attributes = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<Attribute[], Error>({
|
||||
queryKey: ['attributes'],
|
||||
queryFn: fetchAttributes,
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createAttribute,
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['attributes'] })
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: updateAttribute,
|
||||
onSuccess: updated => {
|
||||
client.setQueryData<Attribute[]>(
|
||||
['attributes'],
|
||||
old => old?.map(attr => (attr.id === updated.id ? updated : attr)) ?? []
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteAttribute,
|
||||
onSuccess: deletedId => {
|
||||
client.setQueryData<Attribute[]>(
|
||||
['attributes'],
|
||||
old => old?.filter(attr => attr.id !== deletedId) ?? []
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
// Утилитарные функции
|
||||
const getRequiredAttributes = () =>
|
||||
attributes.filter(attr => attr.is_required)
|
||||
|
||||
const getOptionalAttributes = () =>
|
||||
attributes.filter(attr => !attr.is_required)
|
||||
|
||||
const getAttributeById = (id: string) =>
|
||||
attributes.find(attr => attr.id === id)
|
||||
|
||||
const categoryValue = useMemo(
|
||||
() => ({
|
||||
attributes,
|
||||
isLoading:
|
||||
isLoading ||
|
||||
createMutation.isPending ||
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending,
|
||||
error:
|
||||
error ||
|
||||
createMutation.error ||
|
||||
updateMutation.error ||
|
||||
deleteMutation.error,
|
||||
refetchAttributes: refetch,
|
||||
addAttribute: createMutation.mutateAsync,
|
||||
updateAttribute: updateMutation.mutateAsync,
|
||||
deleteAttribute: deleteMutation.mutateAsync,
|
||||
getRequiredAttributes,
|
||||
getOptionalAttributes,
|
||||
getAttributeById,
|
||||
}),
|
||||
[
|
||||
attributes,
|
||||
isLoading,
|
||||
error,
|
||||
createMutation,
|
||||
updateMutation,
|
||||
deleteMutation,
|
||||
refetch,
|
||||
]
|
||||
)
|
||||
|
||||
const templateAttributesValue = useMemo(
|
||||
() => ({
|
||||
getTemplateAttributes: fetchTemplateAttributes,
|
||||
setTemplateAttributes: setTemplateAttributes,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<CategoryContext.Provider value={categoryValue}>
|
||||
<TemplateAttributesContext.Provider value={templateAttributesValue}>
|
||||
{children}
|
||||
</TemplateAttributesContext.Provider>
|
||||
</CategoryContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useCategoryContext = (): CategoryContextType => {
|
||||
const context = useContext(CategoryContext)
|
||||
if (!context) {
|
||||
throw new Error('useCategoryContext must be used within a CategoryProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const useTemplateAttributesContext =
|
||||
(): TemplateAttributesContextType => {
|
||||
const context = useContext(TemplateAttributesContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useTemplateAttributesContext must be used within a CategoryProvider'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Хук для работы с атрибутами конкретного шаблона
|
||||
export const useTemplateAttributes = (templateId: string) => {
|
||||
const client = useQueryClient()
|
||||
const { getTemplateAttributes, setTemplateAttributes } =
|
||||
useTemplateAttributesContext()
|
||||
|
||||
const {
|
||||
data: templateAttributes = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<TemplateAttributeDetail[], Error>({
|
||||
queryKey: ['templateAttributes', templateId],
|
||||
queryFn: () => getTemplateAttributes(templateId),
|
||||
enabled: !!templateId,
|
||||
})
|
||||
|
||||
const setAttributesMutation = useMutation({
|
||||
mutationFn: (
|
||||
attributes: Array<{ attribute_id: string; value: string | null }>
|
||||
) => setTemplateAttributes(templateId, attributes),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['templateAttributes', templateId] })
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
templateAttributes,
|
||||
isLoading: isLoading || setAttributesMutation.isPending,
|
||||
error: error || setAttributesMutation.error,
|
||||
refetch,
|
||||
setAttributes: setAttributesMutation.mutateAsync,
|
||||
}
|
||||
}
|
||||
|
||||
// Для обратной совместимости
|
||||
export const useCategoryFields = useCategoryContext
|
||||
|
||||
export default CategoryProvider
|
||||
@@ -125,6 +125,9 @@ export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
|
||||
deleteMutation.status,
|
||||
deleteMutation.error,
|
||||
refetch,
|
||||
createMutation.mutateAsync,
|
||||
updateMutation.mutateAsync,
|
||||
deleteMutation.mutateAsync,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -141,3 +144,5 @@ export const useTemplateContext = (): TemplateContextType => {
|
||||
throw new Error('useTemplateContext must be used within TemplateProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default TemplateProvider
|
||||
|
||||
Reference in New Issue
Block a user