Единый интерфейс элементов

This commit is contained in:
2025-07-22 19:44:49 +03:00
parent 553da77a87
commit 3fc352f8e9
36 changed files with 2108 additions and 675 deletions

View 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>
),
}

View 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>
),
}

View 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)
}

View File

@@ -0,0 +1,7 @@
export const ExtraSettingsBadge = () => {
return (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют
</div>
)
}

View File

@@ -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,

View 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

View File

@@ -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