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

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

@@ -21,6 +21,7 @@
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",

View File

@@ -2,6 +2,7 @@ import { FC, useEffect } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom'
import { CategoryProvider } from '@/entitiy/template/model/CategoryContext'
import { TemplateProvider } from '@/entitiy/template/model/TemplateContext'
import {
setGlobalToastContext,
@@ -33,6 +34,7 @@ const App: FC = () => {
<QueryClientProvider client={queryClient}>
<ToastProvider>
<ToastInitializer />
<CategoryProvider>
<TemplateProvider>
<Routes>
<Route path="/" element={<Layout />}>
@@ -53,6 +55,7 @@ const App: FC = () => {
</Route>
</Routes>
</TemplateProvider>
</CategoryProvider>
</ToastProvider>
</QueryClientProvider>
)

View File

@@ -4,7 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
import { store } from './store'
import { initializeElementRegistry } from '../lib/element-registry-init'
import { initializeElementRegistry } from '@/entitiy/element/model/interface'
import App from './App'
import './index.css'
@@ -17,5 +17,5 @@ createRoot(document.getElementById('root') as HTMLElement).render(
<Provider store={store}>
<App />
</Provider>
</BrowserRouter>,
</BrowserRouter>
)

View File

@@ -3,7 +3,7 @@ import { Button } from '@/component/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
import { Input } from '@/component/ui/input'
import { Label } from '@/component/ui/label'
import { ElementDefinition } from '@/lib/element-registry'
import { ElementDefinition } from '@/entitiy/element/model/interface'
import {
AlertCircle,
Check,
@@ -418,9 +418,10 @@ export const calibrationConditionsDefinition: ElementDefinition<
CalibrationConditionsConfig,
CalibrationConditions
> = {
type: 'calibration-conditions',
type: 'calibration_conditions',
label: 'Условия калибровки',
icon: <Thermometer className="h-4 w-4" />,
version: 1,
defaultConfig: {
targetCells: [],
defaultConditions: {

View File

@@ -1,4 +1,4 @@
import { ElementDefinition } from '@/lib/element-registry'
import { ElementDefinition } from '@/entitiy/element/model/interface'
import { CheckSquare } from 'lucide-react'
interface CheckboxConfig {
@@ -11,11 +11,13 @@ export const checkboxDefinition: ElementDefinition<CheckboxConfig, boolean> = {
type: 'checkbox',
label: 'Чекбокс',
icon: <CheckSquare className="h-4 w-4" />,
version: 1,
defaultConfig: {
placeholder: '',
required: false,
targetCells: [],
},
mapToCells: config => config.targetCells || [],
Editor: () => (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют
@@ -37,7 +39,7 @@ export const checkboxDefinition: ElementDefinition<CheckboxConfig, boolean> = {
<input
type="checkbox"
checked={value || false}
onChange={(e) => onChange?.(e.target.checked)}
onChange={e => onChange?.(e.target.checked)}
required={config.required}
className="h-4 w-4"
/>

View File

@@ -1,5 +1,5 @@
import { Input } from '@/component/ui/input'
import { ElementDefinition } from '@/lib/element-registry'
import { ElementDefinition } from '@/entitiy/element/model/interface'
import { Calendar } from 'lucide-react'
interface DateConfig {
@@ -11,10 +11,12 @@ export const dateDefinition: ElementDefinition<DateConfig, string> = {
type: 'date',
label: 'Дата',
icon: <Calendar className="h-4 w-4" />,
version: 1,
defaultConfig: {
required: false,
targetCells: [],
},
mapToCells: config => config.targetCells || [],
Editor: ({ config, onChange }) => (
<div className="space-y-4">
<div className="flex items-center space-x-2">

View File

@@ -1,5 +1,5 @@
import { Input } from '@/component/ui/input'
import { ElementDefinition } from '@/lib/element-registry'
import { ElementDefinition } from '@/entitiy/element/model/interface'
import { Hash } from 'lucide-react'
interface NumberConfig {
@@ -12,11 +12,13 @@ export const numberDefinition: ElementDefinition<NumberConfig, number> = {
type: 'number',
label: 'Число',
icon: <Hash className="h-4 w-4" />,
version: 1,
defaultConfig: {
placeholder: '',
required: false,
targetCells: [],
},
mapToCells: config => config.targetCells || [],
Editor: () => (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют

View File

@@ -1,6 +1,6 @@
import { Button } from '@/component/ui/button'
import { Input } from '@/component/ui/input'
import { ElementDefinition } from '@/lib/element-registry'
import { ElementDefinition } from '@/entitiy/element/model/interface'
import { ElementOption } from '@/type/template'
import { CheckSquare, Plus, Trash2 } from 'lucide-react'
@@ -15,12 +15,14 @@ export const radioDefinition: ElementDefinition<RadioConfig, string> = {
type: 'radio',
label: 'Радиокнопки',
icon: <CheckSquare className="h-4 w-4" />,
version: 1,
defaultConfig: {
placeholder: '',
required: false,
options: [],
targetCells: [],
},
mapToCells: config => config.targetCells || [],
Editor: ({ config, onChange }) => {
const handleAddOption = () => {
onChange({

View File

@@ -7,7 +7,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/component/ui/select'
import { ElementDefinition } from '@/lib/element-registry'
import { ElementDefinition } from '@/entitiy/element/model/interface'
import { ElementOption } from '@/type/template'
import { ChevronDown, Plus, Trash2 } from 'lucide-react'
@@ -22,12 +22,14 @@ export const selectDefinition: ElementDefinition<SelectConfig, string> = {
type: 'select',
label: 'Выпадающий список',
icon: <ChevronDown className="h-4 w-4" />,
version: 1,
defaultConfig: {
placeholder: '',
required: false,
options: [],
targetCells: [],
},
mapToCells: config => config.targetCells || [],
Editor: ({ config, onChange }) => {
const handleAddOption = () => {
onChange({

View File

@@ -1,45 +0,0 @@
import { Input } from '@/component/ui/input'
import { ElementDefinition } from '@/lib/element-registry'
import { Type } from 'lucide-react'
interface TextConfig {
placeholder?: string
required?: boolean
targetCells: any[]
}
export const textDefinition: ElementDefinition<TextConfig, string> = {
type: 'text',
label: 'Текстовое поле',
icon: <Type className="h-4 w-4" />,
defaultConfig: {
placeholder: '',
required: false,
targetCells: [],
},
Editor: () => (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют
</div>
),
Preview: ({ config }) => (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<Input
placeholder={config.placeholder || 'Введите текст'}
disabled
className="w-full"
/>
</div>
</div>
),
Render: ({ config, value, onChange }) => (
<Input
placeholder={config.placeholder || 'Введите текст'}
value={value || ''}
onChange={e => onChange?.(e.target.value)}
required={config.required}
/>
),
}

View File

@@ -1,5 +1,5 @@
import { Textarea } from '@/component/ui/textarea'
import { ElementDefinition } from '@/lib/element-registry'
import { ElementDefinition } from '@/entitiy/element/model/interface'
import { FileText } from 'lucide-react'
interface TextareaConfig {
@@ -12,11 +12,13 @@ export const textareaDefinition: ElementDefinition<TextareaConfig, string> = {
type: 'textarea',
label: 'Многострочный текст',
icon: <FileText className="h-4 w-4" />,
version: 1,
defaultConfig: {
placeholder: '',
required: false,
targetCells: [],
},
mapToCells: config => config.targetCells || [],
Editor: () => (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют

View File

@@ -1,4 +1,5 @@
export { buttonGroupDefinition } from './ButtonGroupElement'
export { textDefinition } from '@/entitiy/element/model/implementations/TextElement'
export { buttonGroupDefinition } from '../../entitiy/element/model/implementations/ButtonGroup'
export { calibrationConditionsDefinition } from './CalibrationConditionsElement'
export { checkboxDefinition } from './CheckboxElement'
export { dateDefinition } from './DateElement'
@@ -6,4 +7,3 @@ export { numberDefinition } from './NumberElement'
export { radioDefinition } from './RadioElement'
export { selectDefinition } from './SelectElement'
export { textareaDefinition } from './TextareaElement'
export { textDefinition } from './TextElement'

View File

@@ -1,4 +1,4 @@
import { Save } from 'lucide-react'
import { Redo2, Save, Undo2 } from 'lucide-react'
import { memo } from 'react'
import { FormulaBarProps } from '../types'
import { getColumnLabel } from '../utils'
@@ -144,6 +144,25 @@ export const FormulaBar = memo(
)}
</div>
)}
{/* Кнопки отмены и повтора */}
{
<div className="flex items-center gap-1">
<button
disabled={true}
className="flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
title="Отменить (Ctrl+Z)"
>
<Undo2 className="h-3 w-3" />
</button>
<button
disabled={true}
className="flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
title="Повторить (Ctrl+Y)"
>
<Redo2 className="h-3 w-3" />
</button>
</div>
}
{/* Кнопка ручного сохранения */}
{onManualSave && (
<button

View File

@@ -4,6 +4,9 @@ import { COL_COUNT, ROW_COUNT, SHEET_NAMES, SpreadsheetProps } from '../types'
import { getColumnLabel } from '../utils'
import { CellRenderer } from './CellRenderer'
// Создаем типизированный компонент для совместимости с React 18
const Grid = VariableSizeGrid as unknown as React.ComponentType<any>
// --- Spreadsheet Component ---
export const Spreadsheet = ({
sheetType,
@@ -274,9 +277,11 @@ export const Spreadsheet = ({
/>
)
})()}
<VariableSizeGrid
ref={(el: VariableSizeGrid | null) => {
<Grid
ref={(el: any) => {
if (gridRefs.current) {
gridRefs.current[sheetType] = el
}
}}
columnCount={COL_COUNT}
rowCount={ROW_COUNT}
@@ -287,13 +292,13 @@ export const Spreadsheet = ({
itemData={itemData}
overscanColumnCount={20}
overscanRowCount={10}
onScroll={e => {
onScroll={(e: any) => {
setScrollPos({ left: e.scrollLeft, top: e.scrollTop })
onGridScroll(sheetType, e)
}}
>
{CellRenderer}
</VariableSizeGrid>
</Grid>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { ElementDefinition } from '@/entitiy/element/model/interface'
import { parseCellAddress } from '@/lib/cell-utils'
import { ElementDefinition } from '@/lib/element-registry'
import { CellTarget } from '@/type/template'
import { Weight } from 'lucide-react'
import { StandardsEditor } from './StandardsEditor'
@@ -18,6 +18,7 @@ export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
type: 'standards',
label: 'Измерительные эталоны',
icon: <Weight className="h-4 w-4" />,
version: 1,
defaultConfig: {
registryNumber: '',
sheet: 'Report',

View File

@@ -0,0 +1,177 @@
import { Button } from '@/component/ui/button'
import { Input } from '@/component/ui/input'
import { Label } from '@/component/ui/label'
import { ChevronDown, X } from 'lucide-react'
import React, { useEffect, useRef, useState } from 'react'
interface AutocompleteInputProps {
label: string
value: string
onValueChange: (value: string) => void
suggestions: string[]
placeholder?: string
disabled?: boolean
}
export const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
label,
value,
onValueChange,
suggestions,
placeholder = '',
disabled = false,
}) => {
const [isOpen, setIsOpen] = useState(false)
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([])
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// Фильтруем предложения на основе введенного текста
useEffect(() => {
if (value.trim() === '') {
setFilteredSuggestions(suggestions)
} else {
const filtered = suggestions.filter(suggestion =>
suggestion.toLowerCase().includes(value.toLowerCase())
)
setFilteredSuggestions(filtered)
}
setHighlightedIndex(-1)
}, [value, suggestions])
// Закрытие выпадающего списка при клике вне компонента
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onValueChange(e.target.value)
setIsOpen(true)
}
const handleSuggestionClick = (suggestion: string) => {
onValueChange(suggestion)
setIsOpen(false)
inputRef.current?.blur()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen || filteredSuggestions.length === 0) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setHighlightedIndex(prev =>
prev < filteredSuggestions.length - 1 ? prev + 1 : 0
)
break
case 'ArrowUp':
e.preventDefault()
setHighlightedIndex(prev =>
prev > 0 ? prev - 1 : filteredSuggestions.length - 1
)
break
case 'Enter':
e.preventDefault()
if (highlightedIndex >= 0) {
handleSuggestionClick(filteredSuggestions[highlightedIndex])
}
break
case 'Escape':
setIsOpen(false)
inputRef.current?.blur()
break
}
}
const clearValue = () => {
onValueChange('')
inputRef.current?.focus()
}
return (
<div className="space-y-2">
<Label className="text-sm font-medium">{label}</Label>
<div className="relative">
<div className="relative">
<Input
ref={inputRef}
value={value}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => setIsOpen(true)}
placeholder={placeholder}
disabled={disabled}
className="pr-16"
/>
<div className="absolute inset-y-0 right-0 flex items-center space-x-1 pr-2">
{value && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={clearValue}
disabled={disabled}
>
<X className="h-3 w-3" />
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setIsOpen(!isOpen)}
disabled={disabled}
>
<ChevronDown
className={`h-3 w-3 transition-transform ${
isOpen ? 'rotate-180' : ''
}`}
/>
</Button>
</div>
</div>
{/* Выпадающий список предложений */}
{isOpen && filteredSuggestions.length > 0 && !disabled && (
<div
ref={dropdownRef}
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border bg-popover text-popover-foreground shadow-md"
>
{filteredSuggestions.map((suggestion, index) => (
<div
key={suggestion}
className={`cursor-pointer px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground ${
index === highlightedIndex
? 'bg-accent text-accent-foreground'
: ''
}`}
onClick={() => handleSuggestionClick(suggestion)}
onMouseEnter={() => setHighlightedIndex(index)}
>
{suggestion}
</div>
))}
</div>
)}
</div>
</div>
)
}
export default AutocompleteInput

View File

@@ -0,0 +1,134 @@
import { Input } from '@/component/ui/input'
import { Label } from '@/component/ui/label'
import { useCategoryContext } from '@/entitiy/template/model/CategoryContext'
import { Attribute } from '@/type/template'
import { AlertCircle } from 'lucide-react'
import React from 'react'
interface AttributeValue {
attributeId: string
value: string | number
}
interface CategoryFieldsSelectorProps {
selectedValues: AttributeValue[]
onValuesChange: (values: AttributeValue[]) => void
disabled?: boolean
}
export const CategoryFieldsSelector: React.FC<CategoryFieldsSelectorProps> = ({
selectedValues,
onValuesChange,
disabled = false,
}) => {
const {
attributes,
getRequiredAttributes,
getOptionalAttributes,
isLoading,
} = useCategoryContext()
const requiredAttributes = getRequiredAttributes()
const optionalAttributes = getOptionalAttributes()
const getAttributeValue = (attributeId: string): string => {
const value = selectedValues.find(v => v.attributeId === attributeId)?.value
return value?.toString() || ''
}
const updateAttributeValue = (
attributeId: string,
value: string | number
) => {
const newValues = selectedValues.filter(v => v.attributeId !== attributeId)
if (value !== '' && value !== null && value !== undefined) {
newValues.push({ attributeId, value })
}
onValuesChange(newValues)
}
const renderAttribute = (attribute: Attribute) => {
const value = getAttributeValue(attribute.id)
const fieldProps = {
disabled,
}
return (
<div key={attribute.id} className="space-y-2">
<Label htmlFor={attribute.id} className="text-sm font-medium">
{attribute.name}
{attribute.is_required && (
<span className="ml-1 text-red-500">*</span>
)}
</Label>
{/* Простое текстовое поле для всех атрибутов */}
<Input
id={attribute.id}
value={value}
onChange={e => updateAttributeValue(attribute.id, e.target.value)}
placeholder={`Введите ${attribute.name.toLowerCase()}`}
{...fieldProps}
/>
{attribute.is_required && !value && (
<p className="flex items-center gap-1 text-xs text-red-500">
<AlertCircle className="h-3 w-3" />
Это поле обязательно для заполнения
</p>
)}
</div>
)
}
if (isLoading) {
return (
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
Загрузка атрибутов...
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Обязательные атрибуты */}
{requiredAttributes.length > 0 && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Label className="text-sm font-semibold text-red-600">
Обязательные атрибуты
</Label>
</div>
<div className="space-y-4">
{requiredAttributes.map(renderAttribute)}
</div>
</div>
)}
{/* Необязательные атрибуты */}
{optionalAttributes.length > 0 && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Label className="text-sm font-semibold">
Дополнительные атрибуты
</Label>
</div>
<div className="space-y-4">
{optionalAttributes.map(renderAttribute)}
</div>
</div>
)}
{attributes.length === 0 && (
<div className="py-4 text-center">
<p className="text-sm text-muted-foreground">Атрибуты не настроены</p>
</div>
)}
</div>
)
}
export default CategoryFieldsSelector

View File

@@ -1,7 +1,7 @@
import {
getAvailableElementTypes,
getElementDefinition,
} from '@/lib/element-registry'
} from '@/entitiy/element/model/interface'
import {
autoArrangeElements,
findFreePosition,
@@ -109,8 +109,6 @@ const CellTargetEditor: React.FC<{
<SelectContent>
<SelectItem value="L">L (Левый)</SelectItem>
<SelectItem value="R">R (Правый)</SelectItem>
<SelectItem value="Report">Report</SelectItem>
<SelectItem value="Calculations">Calculations</SelectItem>
</SelectContent>
</Select>
</div>
@@ -254,7 +252,7 @@ const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({
</div>
)
case 'calibration-conditions':
case 'calibration_conditions':
return (
<div className="space-y-2">
<div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-3 py-2">
@@ -287,7 +285,7 @@ const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({
</div>
)
case 'button-group':
case 'button_group':
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5">
@@ -381,7 +379,7 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
const needsOptions =
formData.type === 'select' ||
formData.type === 'radio' ||
formData.type === 'button-group'
formData.type === 'button_group'
// Если есть определение элемента в реестре, используем его редактор
if (elementDefinition && elementDefinition.Editor) {
@@ -395,35 +393,21 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
value={formData.type}
onValueChange={value => {
const newType = value as ElementType
// console.log('Element type changed to:', newType)
const elementDefinition = getElementDefinition(newType)
setFormData(prev => {
const updates: Partial<TemplateElement> = { type: newType }
// Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию
if (
newType === 'standards' ||
newType === 'calibration-conditions'
) {
// console.log(
// `${newType} type selected, getting element definition`
// )
const elementDefinition = getElementDefinition(newType)
// console.log('Element definition:', elementDefinition)
const updates: Partial<TemplateElement> = {
type: newType,
// Сбрасываем targetCells при смене типа
targetCells: [],
}
// Применяем конфиг по умолчанию из определения элемента
if (elementDefinition && elementDefinition.defaultConfig) {
const defaultConfig =
elementDefinition.defaultConfig as any
// console.log('Default config:', defaultConfig)
updates.targetCells =
elementDefinition.mapToCells?.(defaultConfig) || []
// console.log('Generated targetCells:', updates.targetCells)
}
Object.assign(updates, elementDefinition.defaultConfig)
}
const newFormData = { ...prev, ...updates }
// console.log('New form data:', newFormData)
return newFormData
return { ...prev, ...updates }
})
}}
>
@@ -520,40 +504,21 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
value={formData.type}
onValueChange={value => {
const newType = value as ElementType
// console.log('Element type changed to (fallback):', newType)
const elementDefinition = getElementDefinition(newType)
setFormData(prev => {
const updates: Partial<TemplateElement> = { type: newType }
// Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию
if (
newType === 'standards' ||
newType === 'calibration-conditions'
) {
// console.log(
// `${newType} type selected (fallback), getting element definition`
// )
const elementDefinition = getElementDefinition(newType)
// console.log(
// 'Element definition (fallback):',
// elementDefinition
// )
const updates: Partial<TemplateElement> = {
type: newType,
// Сбрасываем targetCells при смене типа
targetCells: [],
}
// Применяем конфиг по умолчанию из определения элемента
if (elementDefinition && elementDefinition.defaultConfig) {
const defaultConfig = elementDefinition.defaultConfig as any
// console.log('Default config (fallback):', defaultConfig)
updates.targetCells =
elementDefinition.mapToCells?.(defaultConfig) || []
// console.log(
// 'Generated targetCells (fallback):',
// updates.targetCells
// )
}
Object.assign(updates, elementDefinition.defaultConfig)
}
const newFormData = { ...prev, ...updates }
// console.log('New form data (fallback):', newFormData)
return newFormData
return { ...prev, ...updates }
})
}}
>
@@ -760,23 +725,14 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
}
const handleAddElement = () => {
// console.log('handleAddElement called with formData:', formData)
if (!formData.label) {
// console.log('Validation failed: missing label')
return
}
// Для элементов типа "standards", "calibration-conditions" и "button-group" не требуем targetCells, так как они генерируются автоматически или не нужны
if (
formData.type !== 'standards' &&
formData.type !== 'calibration-conditions' &&
formData.type !== 'button-group' &&
!formData.targetCells?.length
) {
// console.log(
// 'Validation failed: missing targetCells for element that requires manual targetCells'
// )
// Получаем определение элемента
const elementDefinition = getElementDefinition(formData.type as ElementType)
if (!elementDefinition) {
console.error(`Element definition for ${formData.type} not found`)
return
}
@@ -790,25 +746,10 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
layoutSettings.gridSize
)
// Специальная обработка для элементов с автоматической генерацией targetCells
// Используем targetCells из формы или генерируем через mapToCells
let targetCells = formData.targetCells || []
if (
formData.type === 'standards' ||
formData.type === 'calibration-conditions'
) {
// Генерируем targetCells автоматически, если их нет
if (!targetCells.length) {
const elementDefinition = getElementDefinition(formData.type)
if (elementDefinition && elementDefinition.defaultConfig) {
const defaultConfig = elementDefinition.defaultConfig as any
targetCells = elementDefinition.mapToCells?.(defaultConfig) || []
}
}
}
// Для button-group не нужны targetCells, так как это UI элемент
if (formData.type === 'button-group') {
targetCells = []
targetCells = elementDefinition.mapToCells(formData as any) || []
}
const newElement: TemplateElement = {
@@ -837,14 +778,20 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
const handleUpdateElement = () => {
if (!editingElement || !formData.label) return
// Для элементов типа "standards", "calibration-conditions" и "button-group" не требуем targetCells, так как они генерируются автоматически или не нужны
if (
formData.type !== 'standards' &&
formData.type !== 'calibration-conditions' &&
formData.type !== 'button-group' &&
!formData.targetCells?.length
// Получаем определение элемента
const elementDefinition = getElementDefinition(
editingElement.type as ElementType
)
if (!elementDefinition) {
console.error(`Element definition for ${editingElement.type} not found`)
return
}
// Используем targetCells из формы или генерируем через mapToCells
let targetCells = formData.targetCells || []
if (!targetCells.length) {
targetCells = elementDefinition.mapToCells(formData as any) || []
}
const updatedData = {
...formData,
@@ -852,6 +799,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
formData.label?.toLowerCase().replace(/\s+/g, '_') ||
formData.name ||
'',
targetCells,
}
onElementUpdate(editingElement.id, updatedData)
@@ -943,14 +891,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
</Button>
<Button
onClick={handleAddElement}
disabled={
disabled ||
!formData.label ||
(formData.type !== 'standards' &&
formData.type !== 'calibration-conditions' &&
formData.type !== 'button-group' &&
!formData.targetCells?.length)
}
disabled={disabled || !formData.label}
>
<Plus className="mr-2 h-4 w-4" />
Добавить элемент

View File

@@ -0,0 +1,225 @@
import { AutocompleteInput } from '@/component/TemplateManager/AutocompleteInput'
import { Button } from '@/component/ui/button'
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarSeparator,
} from '@/component/ui/sidebar'
import { useCategoryContext } from '@/entitiy/template/model/CategoryContext'
import { Template } from '@/type/template'
import { Filter, X } from 'lucide-react'
import React, { useMemo } from 'react'
import { Label } from '../ui/label'
export interface TemplateFilters {
name: string
description: string
attributes: { [attributeId: string]: string } // attributeId -> value для фильтрации
}
interface TemplateFilterSidebarProps {
templates: Template[]
filters: TemplateFilters
onFiltersChange: (filters: TemplateFilters) => void
}
export const TemplateFilterSidebar: React.FC<TemplateFilterSidebarProps> = ({
templates,
filters,
onFiltersChange,
}) => {
const { attributes = [], isLoading: attributesLoading } = useCategoryContext()
// Получаем уникальные значения для автокомплита
const nameSuggestions = useMemo(() => {
const names = templates.map(t => t.name).filter(Boolean)
return [...new Set(names)].sort()
}, [templates])
const descriptionSuggestions = useMemo(() => {
const descriptions = templates
.map(t => t.description)
.filter(Boolean) as string[]
return [...new Set(descriptions)].sort()
}, [templates])
// Обработчики изменения фильтров
const updateFilters = (updates: Partial<TemplateFilters>) => {
onFiltersChange({ ...filters, ...updates })
}
const updateAttributeFilter = (attributeId: string, value: string) => {
const newAttributes = { ...filters.attributes }
if (value) {
newAttributes[attributeId] = value
} else {
delete newAttributes[attributeId]
}
updateFilters({ attributes: newAttributes })
}
const clearAllFilters = () => {
onFiltersChange({
name: '',
description: '',
attributes: {},
})
}
const hasActiveFilters =
filters.name ||
filters.description ||
Object.keys(filters.attributes).length > 0
const getAttributeName = (attributeId: string) => {
const attribute = attributes.find(a => a.id === attributeId)
return attribute?.name || 'Неизвестный атрибут'
}
return (
<Sidebar>
<SidebarHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4" />
<span className="font-semibold">Фильтры</span>
</div>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="h-auto p-1 text-xs"
>
<X className="mr-1 h-3 w-3" />
Очистить
</Button>
)}
</div>
</SidebarHeader>
<SidebarContent>
{/* Фильтр по названию */}
<SidebarGroup>
<Label className="text-sm font-medium">Поиск по названию</Label>
<SidebarGroupContent>
<AutocompleteInput
label=""
value={filters.name}
onValueChange={value => updateFilters({ name: value })}
suggestions={nameSuggestions}
placeholder="Введите название"
/>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
{/* Фильтр по описанию */}
<SidebarGroup>
<Label className="text-sm font-medium">Поиск по описанию</Label>
<SidebarGroupContent>
<AutocompleteInput
label=""
value={filters.description}
onValueChange={value => updateFilters({ description: value })}
suggestions={descriptionSuggestions}
placeholder="Введите описание"
/>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
{/* Активные фильтры по атрибутам */}
{Object.keys(filters.attributes).length > 0 && (
<>
<SidebarGroup>
<SidebarGroupLabel>Активные фильтры</SidebarGroupLabel>
<SidebarGroupContent>
<div className="space-y-2">
{Object.entries(filters.attributes).map(
([attributeId, value]) => (
<div
key={attributeId}
className="flex items-center justify-between text-xs"
>
<span className="text-muted-foreground">
{getAttributeName(attributeId)}:
</span>
<div className="flex items-center gap-1">
<span className="font-medium">{value}</span>
<button
onClick={() =>
updateAttributeFilter(attributeId, '')
}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
)
)}
</div>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
</>
)}
{/* Фильтры по атрибутам */}
{!attributesLoading && attributes.length > 0 && (
<SidebarGroup>
{/* <SidebarGroupLabel>Фильтры по атрибутам</SidebarGroupLabel> */}
<SidebarGroupContent>
<div className="space-y-3">
{attributes.map(attribute => (
<div key={attribute.id}>
<AutocompleteInput
label={attribute.name}
value={filters.attributes[attribute.id] || ''}
onValueChange={value =>
updateAttributeFilter(attribute.id, value)
}
suggestions={[]} // Пока без автодополнения
placeholder={`Фильтр по ${attribute.name.toLowerCase()}`}
/>
</div>
))}
</div>
</SidebarGroupContent>
</SidebarGroup>
)}
{attributesLoading && (
<SidebarGroup>
<SidebarGroupLabel>Атрибуты</SidebarGroupLabel>
<SidebarGroupContent>
<div className="text-xs text-muted-foreground">
Загрузка атрибутов...
</div>
</SidebarGroupContent>
</SidebarGroup>
)}
{!attributesLoading && attributes.length === 0 && (
<SidebarGroup>
<SidebarGroupLabel>Атрибуты</SidebarGroupLabel>
<SidebarGroupContent>
<div className="text-xs text-muted-foreground">
Атрибуты не настроены
</div>
</SidebarGroupContent>
</SidebarGroup>
)}
</SidebarContent>
</Sidebar>
)
}
export default TemplateFilterSidebar

View File

@@ -1,33 +1,19 @@
import {
Calendar,
ChevronDown,
Droplets,
Edit,
Edit3,
Gauge,
Grid,
Move,
Radio,
Thermometer,
Trash2,
Zap,
} from 'lucide-react'
import { Edit, Grid, Move, Trash2 } from 'lucide-react'
import React, { useCallback, useMemo, useState } from 'react'
import { Rnd } from 'react-rnd'
import { getElementDefinition } from '../../entitiy/element/model/interface'
import {
ElementLayout,
FormLayoutSettings,
TemplateElement,
} from '../../type/template'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Textarea } from '../ui/textarea'
// Минимальные размеры для разных типов элементов.
// Если понадобится скорректировать размеры для конкретного элемента,
// достаточно изменить эту таблицу.
const ELEMENT_MIN_SIZE: Record<string, { width: number; height: number }> = {
text: { width: 120, height: 40 },
text: { width: 120, height: 80 },
textarea: { width: 160, height: 80 },
number: { width: 120, height: 40 },
date: { width: 140, height: 40 },
@@ -35,8 +21,8 @@ const ELEMENT_MIN_SIZE: Record<string, { width: number; height: number }> = {
radio: { width: 160, height: 80 },
checkbox: { width: 140, height: 40 },
standards: { width: 200, height: 50 },
'calibration-conditions': { width: 250, height: 60 },
'button-group': { width: 180, height: 50 },
calibration_conditions: { width: 250, height: 60 },
button_group: { width: 180, height: 80 },
}
interface VisualLayoutEditorProps {
@@ -61,26 +47,6 @@ interface DraggableElementProps {
gridSize: number
}
// const ELEMENT_ICONS: Record<ElementType, React.ReactNode> = {
// text: <Type className="h-4 w-4" />,
// textarea: <FileText className="h-4 w-4" />,
// number: <Hash className="h-4 w-4" />,
// date: <Calendar className="h-4 w-4" />,
// select: <ChevronDown className="h-4 w-4" />,
// radio: <CheckSquare className="h-4 w-4" />,
// checkbox: <CheckSquare className="h-4 w-4" />,
// };
// const ELEMENT_COLORS: Record<ElementType, string> = {
// text: 'bg-blue-100 border-blue-300 text-blue-800',
// textarea: 'bg-green-100 border-green-300 text-green-800',
// number: 'bg-purple-100 border-purple-300 text-purple-800',
// date: 'bg-orange-100 border-orange-300 text-orange-800',
// select: 'bg-indigo-100 border-indigo-300 text-indigo-800',
// radio: 'bg-pink-100 border-pink-300 text-pink-800',
// checkbox: 'bg-teal-100 border-teal-300 text-teal-800',
// };
const DraggableElement: React.FC<DraggableElementProps> = React.memo(
({ element, isSelected, onSelect, onUpdate, onDelete, onEdit, gridSize }) => {
const layout = element.layout || {
@@ -145,141 +111,16 @@ const DraggableElement: React.FC<DraggableElementProps> = React.memo(
* behaviour of Rnd and keeps rendering costs minimal.
*/
const previewControl = useMemo(() => {
switch (element.type) {
case 'text':
return (
<Input
readOnly
placeholder={element.placeholder || 'Введите текст'}
className="pointer-events-none w-full bg-white"
/>
)
case 'textarea':
return (
<Textarea
readOnly
rows={3}
placeholder={element.placeholder || 'Введите текст'}
className="pointer-events-none w-full resize-none bg-white"
/>
)
case 'number':
return (
<Input
type="number"
readOnly
placeholder={element.placeholder || '0'}
className="pointer-events-none w-full bg-white"
/>
)
case 'date':
return (
<Input
type="date"
readOnly
className="pointer-events-none w-full bg-white"
/>
)
case 'select':
return (
<div className="pointer-events-none w-full">
<div className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-white px-3 py-2 text-sm">
<span className="text-gray-500">
{element.placeholder || 'Выберите значение'}
</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</div>
</div>
)
case 'radio':
return (
<div className="pointer-events-none space-y-2">
{(element.options?.length
? element.options
: [{ label: 'Вариант 1' }]
).map((opt, idx) => (
<div key={idx} className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-gray-300 bg-white" />
<span className="text-sm text-gray-900">
{opt.label || `Вариант ${idx + 1}`}
</span>
</div>
))}
</div>
)
case 'checkbox':
return (
<div className="pointer-events-none flex items-center gap-2">
<div className="h-4 w-4 rounded border-2 border-gray-300 bg-white" />
<span className="text-sm text-gray-900">
{element.placeholder || element.label || 'Отметить'}
</span>
</div>
)
case 'standards':
return (
<div className="pointer-events-none w-full">
<div className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-white px-3 py-2 text-sm">
<span className="text-gray-500">
{element.placeholder || 'Выберите эталоны'}
</span>
<Calendar className="h-4 w-4 opacity-50" />
</div>
</div>
)
case 'calibration-conditions':
return (
<div className="pointer-events-none w-full">
<div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-3 py-2">
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1">
<Thermometer className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">20°C</span>
</div>
<div className="flex items-center gap-1">
<Droplets className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">65%</span>
</div>
<div className="flex items-center gap-1">
<Gauge className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">101.3кПа</span>
</div>
<div className="flex items-center gap-1">
<Zap className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">220В</span>
</div>
<div className="flex items-center gap-1">
<Radio className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">50Гц</span>
</div>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 p-0">
<Edit3 className="h-3 w-3" />
</Button>
</div>
</div>
)
case 'button-group':
return (
<div className="pointer-events-none w-full">
<div className="flex flex-wrap gap-1.5">
{(element.options?.length
? element.options
: [{ label: 'Кнопка 1' }, { label: 'Кнопка 2' }]
).map((opt, idx) => (
<button
key={idx}
className="rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-all duration-200"
>
{opt.label || `Кнопка ${idx + 1}`}
</button>
))}
</div>
</div>
)
default:
const elementDefinition = getElementDefinition(element.type)
if (!elementDefinition || !elementDefinition.Preview) {
console.error(`Element definition for ${element.type} not found`)
return null
}
return (
<div className="pointer-events-none w-full">
<elementDefinition.Preview config={element as any} />
</div>
)
}, [element])
return (
@@ -307,23 +148,12 @@ const DraggableElement: React.FC<DraggableElementProps> = React.memo(
: 'rounded-md p-1 hover:bg-gray-50/10'
}`}
>
{/* Label and controls (only visible when selected or hovered) */}
{/* Controls (only visible when selected or hovered) */}
<div
className={`mb-1 flex items-center justify-between ${
className={`absolute right-1 top-1 flex items-center gap-1 ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-60'
}`}
>
<div className="flex items-start gap-2">
<div className="flex items-center gap-1">
<span className="truncate text-xs font-medium text-gray-600">
{element.label || element.name || `${element.type} элемент`}
</span>
{element.required && (
<span className="-mt-1.5 inline-block h-1 w-1 rounded-full bg-red-500" />
)}
</div>
</div>
<div className="flex items-center gap-1">
{/* Индикатор ячеек */}
{element.targetCells && element.targetCells.length > 0 && (
<div className="group/cells relative">
@@ -362,18 +192,10 @@ const DraggableElement: React.FC<DraggableElementProps> = React.memo(
<Trash2 className="h-2.5 w-2.5 text-red-600" />
</Button>
</div>
</div>
{/* Real element preview */}
{previewControl && (
<div className="flex-1 space-y-1">
{element.label && (
<label className="pointer-events-none block text-sm font-medium text-gray-900">
{element.label}
</label>
)}
{previewControl}
</div>
<div className="flex-1 space-y-1">{previewControl}</div>
)}
{/* Additional info (only visible on selection to preserve visual clarity & perf) */}
@@ -464,7 +286,7 @@ export const VisualLayoutEditor: React.FC<VisualLayoutEditorProps> = ({
onClick={handleCanvasClick}
>
<div
className="relative mx-auto my-8 bg-white shadow-lg"
className="relative mx-auto my-8 select-none bg-white shadow-lg"
style={{ width: canvasSize.width, height: canvasSize.height }}
onClick={handleCanvasClick}
>

View File

@@ -0,0 +1,416 @@
import { Slot } from '@radix-ui/react-slot'
import { PanelLeftIcon } from 'lucide-react'
import * as React from 'react'
import { Button } from '@/component/ui/button'
import { Input } from '@/component/ui/input'
import { Separator } from '@/component/ui/separator'
import { cn } from '@/lib/utils'
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = '16rem'
const SIDEBAR_WIDTH_MOBILE = '18rem'
const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
type SidebarContextProps = {
state: 'expanded' | 'collapsed'
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.')
}
return context
}
// Simple mobile hook implementation
function useIsMobile() {
const [isMobile, setIsMobile] = React.useState(false)
React.useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
return isMobile
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed'
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right'
variant?: 'sidebar' | 'floating' | 'inset'
collapsible?: 'offcanvas' | 'icon' | 'none'
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === 'none') {
return (
<div
data-slot="sidebar"
className={cn(
'bg-sidebar text-sidebar-foreground flex h-full w-[--sidebar-width] flex-col',
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<div
className="fixed inset-0 z-50 bg-black/50"
onClick={() => setOpenMobile(false)}
>
<div
className={cn(
'fixed inset-y-0 left-0 w-[--sidebar-width] bg-background p-4 text-foreground shadow-lg transition-transform duration-300',
openMobile ? 'translate-x-0' : '-translate-x-full'
)}
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
onClick={e => e.stopPropagation()}
>
<div className="flex h-full w-full flex-col">{children}</div>
</div>
</div>
)
}
return (
<div
className="text-sidebar-foreground group peer hidden md:block"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
'relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+1rem)]'
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]'
)}
/>
<div
data-slot="sidebar-container"
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+1rem+2px)]'
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l',
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex h-full w-full flex-col border-r bg-background group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn('size-7', className)}
onClick={event => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot="sidebar-inset"
className={cn(
'relative flex w-full flex-1 flex-col bg-background',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm',
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn('h-8 w-full bg-background shadow-none', className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn('mx-2 w-auto bg-border', className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div'
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
'outline-hidden flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-foreground/70 ring-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn('w-full text-sm', className)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarProvider,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -1,6 +1,6 @@
import { Button } from '@/component/ui/button'
import { Input } from '@/component/ui/input'
import { ElementDefinition } from '@/lib/element-registry'
import { ElementDefinition } from '@/entitiy/element/model/interface'
import { ElementOption } from '@/type/template'
import { Plus, Square, Trash2 } from 'lucide-react'
@@ -11,15 +11,17 @@ interface ButtonGroupConfig {
targetCells: any[]
layout?: 'horizontal' | 'vertical'
buttonStyle?: 'default' | 'outline' | 'ghost'
name?: string
}
export const buttonGroupDefinition: ElementDefinition<
ButtonGroupConfig,
string
> = {
type: 'button-group',
type: 'button_group',
label: 'Группа кнопок',
icon: <Square className="h-4 w-4" />,
version: 1,
defaultConfig: {
placeholder: '',
required: false,
@@ -27,6 +29,7 @@ export const buttonGroupDefinition: ElementDefinition<
targetCells: [],
layout: 'horizontal',
buttonStyle: 'default',
name: '',
},
// Берём целевые ячейки прямо из конфига, если они заданы в редакторе
mapToCells: cfg => cfg.targetCells || [],
@@ -60,7 +63,7 @@ export const buttonGroupDefinition: ElementDefinition<
return (
<div className="space-y-4">
<div className="space-y-2">
{/* <div className="space-y-2">
<label className="text-sm font-medium">Расположение кнопок</label>
<div className="flex gap-2">
<Button
@@ -78,7 +81,7 @@ export const buttonGroupDefinition: ElementDefinition<
Вертикально
</Button>
</div>
</div>
</div> */}
<div className="space-y-2">
<label className="text-sm font-medium">Стиль кнопок</label>
@@ -133,8 +136,7 @@ export const buttonGroupDefinition: ElementDefinition<
}
/>
<Button
variant="outline"
size="icon"
variant="ghost"
onClick={() => handleRemoveOption(index)}
>
<Trash2 className="h-4 w-4" />
@@ -148,8 +150,12 @@ export const buttonGroupDefinition: ElementDefinition<
},
Preview: ({ config }) => (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
{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'
@@ -177,10 +183,15 @@ export const buttonGroupDefinition: ElementDefinition<
)}
</div>
</div>
</div>
),
Render: ({ config, value, onChange }) => (
<div className="space-y-2">
<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'
@@ -197,7 +208,7 @@ export const buttonGroupDefinition: ElementDefinition<
? '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-border bg-background text-foreground hover:border-primary/30 hover:bg-primary/5'
: 'border-primary bg-primary text-primary-foreground shadow-sm'
}`}
>
{option.label}

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

View File

@@ -1,30 +0,0 @@
import {
buttonGroupDefinition,
calibrationConditionsDefinition,
checkboxDefinition,
dateDefinition,
numberDefinition,
radioDefinition,
selectDefinition,
textareaDefinition,
textDefinition,
} from '@/component/BasicElements'
import { standardsDefinition } from '@/component/StandardsElement/definition'
import { registerElement } from './element-registry'
// Регистрация всех элементов
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

@@ -1,48 +0,0 @@
import { ReactNode } from 'react'
import { CellTarget, ElementType } from '../type/template'
export interface ElementDefinition<Config = any, Value = any> {
/* служебные */
type: ElementType // "text" | "select" | ...
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[] // куда пишем данные
}
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]
}

View File

@@ -14,9 +14,9 @@ import {
SelectValue,
} from '@/component/ui/select'
import { Textarea } from '@/component/ui/textarea'
import { getElementDefinition } from '@/entitiy/element/model/interface'
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
import { cellAddressToCoordinates } from '@/lib/cell-utils'
import { getElementDefinition } from '@/lib/element-registry'
import { useToast } from '@/lib/hooks/useToast'
import { getLatestFileForTemplate } from '@/service/fileApiService'
import { Template, TemplateElement } from '@/type/template'
@@ -118,6 +118,17 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
const renderInput = () => {
switch (element.type) {
case 'text':
// Используем определение элемента из реестра для рендеринга
const textDefinition = getElementDefinition('text')
if (textDefinition && textDefinition.Render) {
return (
<textDefinition.Render
config={element as any}
value={value}
onChange={onChange}
/>
)
}
return (
<Input
placeholder={element.placeholder}
@@ -291,9 +302,9 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
</div>
)
case 'calibration-conditions':
case 'calibration_conditions':
// Используем определение элемента из реестра для рендеринга
const elementDefinition = getElementDefinition('calibration-conditions')
const elementDefinition = getElementDefinition('calibration_conditions')
if (elementDefinition && elementDefinition.Render) {
return (
<elementDefinition.Render
@@ -305,9 +316,9 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
}
return null
case 'button-group':
case 'button_group':
// Используем определение элемента из реестра для рендеринга
const buttonGroupDefinition = getElementDefinition('button-group')
const buttonGroupDefinition = getElementDefinition('button_group')
if (buttonGroupDefinition && buttonGroupDefinition.Render) {
return (
<buttonGroupDefinition.Render
@@ -324,8 +335,19 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
}
}
// Проверяем, использует ли элемент определение из реестра для рендеринга
const elementDefinition = getElementDefinition(element.type)
const usesElementRender =
elementDefinition &&
elementDefinition.Render &&
(element.type === 'text' ||
element.type === 'calibration_conditions' ||
element.type === 'button_group')
return (
<div className="space-y-3">
{/* Показываем label и индикатор ячеек только если элемент не использует свой Render компонент */}
{!usesElementRender && (
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-1">
<label className="text-sm font-medium text-gray-900">
@@ -355,6 +377,32 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
</div>
)}
</div>
)}
{/* Для элементов с собственными Render компонентами показываем только индикатор ячеек */}
{usesElementRender &&
element.targetCells &&
element.targetCells.length > 0 && (
<div className="flex justify-end">
<div className="group/cells relative">
<Grid className="h-3 w-3 cursor-help text-gray-500 opacity-60 transition-opacity hover:opacity-100" />
<div className="pointer-events-none absolute right-0 top-full z-50 mt-2 whitespace-nowrap rounded bg-gray-900 p-2 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/cells:opacity-100">
<div className="mb-1 font-medium">Целевые ячейки:</div>
{element.targetCells.map((cell, i) => (
<div key={i} className="font-mono">
{cell.sheet}!{cell.cell}
{cell.displayName && (
<span className="ml-1 text-gray-300">
({cell.displayName})
</span>
)}
</div>
))}
</div>
</div>
</div>
)}
{renderInput()}
</div>
)
@@ -388,7 +436,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
// Специальная обработка для условий калибровки
if (
element.type === 'calibration-conditions' &&
element.type === 'calibration_conditions' &&
typeof value === 'object' &&
value !== null
) {
@@ -403,6 +451,15 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
cellValue = (value as any)[propsOrder[idx]] ?? ''
}
// Специальная обработка для группы кнопок
if (element.type === 'button_group' && typeof value === 'string') {
// Найдем соответствующий option для получения label
const buttonOption = element.options?.find(
opt => opt.value === value
)
cellValue = buttonOption ? buttonOption.label : value
}
const { row, col } = cellAddressToCoordinates(tc.cell)
const sheetName =
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
@@ -497,7 +554,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
// 2) Объект условия калибровки -> пишем свойства по порядку
if (
el.type === 'calibration-conditions' &&
el.type === 'calibration_conditions' &&
typeof value === 'object' &&
value !== null
) {
@@ -524,7 +581,25 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
return
}
// 3) Примитив или прочие объекты -> одно значение во все ячейки
// 3) Группа кнопок -> записываем label выбранной кнопки
if (el.type === 'button_group' && typeof value === 'string') {
const buttonOption = el.options?.find(opt => opt.value === value)
const cellValue = buttonOption ? buttonOption.label : value
cells.forEach(tc => {
const { row, col } = cellAddressToCoordinates(tc.cell)
const sheetName =
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
engineRef.current.setCellValueWithoutRecalc(
sheetName,
row,
col,
cellValue
)
})
return
}
// 4) Примитив или прочие объекты -> одно значение во все ячейки
cells.forEach(tc => {
const { row, col } = cellAddressToCoordinates(tc.cell)
const sheetName =
@@ -579,6 +654,33 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
// Получаем файл как blob и скачиваем его
const blob = await resp.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
// Получаем имя файла из заголовка Content-Disposition или создаем по умолчанию
const contentDisposition = resp.headers.get('content-disposition')
let filename = `protocol_${template.name}_${new Date().toISOString().split('T')[0]}.xlsx`
if (contentDisposition) {
const matches = contentDisposition.match(
/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
)
if (matches && matches[1]) {
filename = matches[1].replace(/['"]/g, '')
}
}
a.download = filename
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
toast.success('Протокол создан и загружен!')
} catch (err) {
console.error('Ошибка при создании протокола', err)
toast.error('Ошибка при создании протокола')

View File

@@ -1,3 +1,8 @@
import { CategoryFieldsSelector } from '@/component/TemplateManager/CategoryFieldsSelector'
import {
TemplateFilterSidebar,
TemplateFilters,
} from '@/component/TemplateManager/TemplateFilterSidebar'
import { Button } from '@/component/ui/button'
import {
Dialog,
@@ -11,23 +16,42 @@ import {
} from '@/component/ui/dialog'
import { Input } from '@/component/ui/input'
import { Label } from '@/component/ui/label'
import { Separator } from '@/component/ui/separator'
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from '@/component/ui/sidebar'
import { Textarea } from '@/component/ui/textarea'
import { useCategoryContext } from '@/entitiy/template/model/CategoryContext'
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
import TemplateCard from '@/widget/template/ui/TemplateCard'
import clsx from 'clsx'
import { CheckSquare, Plus, Square, Trash2 } from 'lucide-react'
import { useCallback, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
interface AttributeValue {
attributeId: string
value: string | number
}
export const TemplatesOverviewPage = () => {
const { templates, deleteTemplate, addTemplate } = useTemplateContext()
const { isLoading: attributesLoading } = useCategoryContext()
const [selected, setSelected] = useState<Set<string>>(new Set())
const [selectionMode, setSelectionMode] = useState(false)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [newName, setNewName] = useState('')
const [newDescription, setNewDescription] = useState('')
const [newAttributeValues, setNewAttributeValues] = useState<
AttributeValue[]
>([])
const [filters, setFilters] = useState<TemplateFilters>({
name: '',
description: '',
attributes: {},
})
const toggleSelect = useCallback((id: string) => {
setSelected(prev => {
@@ -52,6 +76,8 @@ export const TemplatesOverviewPage = () => {
mergedCells: [],
})
setNewName('')
setNewDescription('')
setNewAttributeValues([])
setIsDialogOpen(false)
}
}
@@ -63,43 +89,102 @@ export const TemplatesOverviewPage = () => {
})
}
return (
<main className="mx-auto max-w-7xl p-6">
<header className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">Шаблоны протоколов</h1>
<div className="flex gap-3">
{!!selected.size && (
<Button variant="destructive" onClick={removeSelected}>
<Trash2 className="mr-2 h-4 w-4" /> {selected.size}
</Button>
)}
// Фильтрация шаблонов
const filteredTemplates = useMemo(() => {
return templates.filter(template => {
// Фильтр по названию
if (
filters.name &&
!template.name.toLowerCase().includes(filters.name.toLowerCase())
) {
return false
}
// Фильтр по описанию
if (
filters.description &&
(!template.description ||
!template.description
.toLowerCase()
.includes(filters.description.toLowerCase()))
) {
return false
}
// Примечание: Фильтрация по атрибутам пока отключена,
// так как атрибуты теперь хранятся отдельно от шаблонов
// и требуют отдельного запроса к API для каждого шаблона
return true
})
}, [templates, filters])
return (
<SidebarProvider defaultOpen={true}>
<TemplateFilterSidebar
templates={templates}
filters={filters}
onFiltersChange={setFilters}
/>
<SidebarInset>
<header className="flex items-center gap-2 border-b px-4 py-2">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-2 h-4" />
<h1 className="text-lg font-semibold">Шаблоны</h1>
<div className="ml-auto flex items-center gap-2">
{selectionMode && (
<>
<span className="text-sm text-muted-foreground">
Выбрано: {selected.size}
</span>
<Button
variant={selectionMode ? 'secondary' : 'outline'}
size="sm"
onClick={removeSelected}
disabled={selected.size === 0}
variant="destructive"
>
<Trash2 className="mr-1 h-4 w-4" />
Удалить
</Button>
<Button
size="sm"
variant="outline"
onClick={toggleSelectionMode}
>
{selectionMode ? (
<>
<Square className="mr-2 h-4 w-4" /> Отменить
</>
) : (
<>
<CheckSquare className="mr-2 h-4 w-4" /> Выбрать
Отмена
</Button>
</>
)}
{!selectionMode && (
<>
<Button
size="sm"
variant="outline"
onClick={toggleSelectionMode}
disabled={templates.length === 0}
>
{selected.size === templates.length ? (
<Square className="mr-1 h-4 w-4" />
) : (
<CheckSquare className="mr-1 h-4 w-4" />
)}
Выбрать
</Button>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" /> Создать
<Button size="sm">
<Plus className="mr-1 h-4 w-4" />
Создать
</Button>
</DialogTrigger>
<DialogContent>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Новый шаблон</DialogTitle>
<DialogTitle>Создать новый шаблон</DialogTitle>
<DialogDescription>
Укажите имя вашего нового шаблона протокола.
Заполните основную информацию о шаблоне
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
@@ -112,11 +197,14 @@ export const TemplatesOverviewPage = () => {
className="col-span-3"
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="Введите название"
placeholder="Введите название шаблона"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="template-description" className="text-right">
<Label
htmlFor="template-description"
className="text-right"
>
Описание
</Label>
<Textarea
@@ -127,6 +215,19 @@ export const TemplatesOverviewPage = () => {
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={newAttributeValues}
onValuesChange={setNewAttributeValues}
/>
)}
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
@@ -136,16 +237,37 @@ export const TemplatesOverviewPage = () => {
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</div>
</header>
<main className="flex-1 p-6">
{/* Статистика фильтрации */}
{(filters.name ||
filters.description ||
Object.keys(filters.attributes).length > 0) && (
<div className="mb-4 rounded-lg bg-muted/50 p-3">
<div className="text-sm text-muted-foreground">
Показано {filteredTemplates.length} из {templates.length}{' '}
шаблонов
{Object.keys(filters.attributes).length > 0 && (
<span className="ml-2">
Фильтры по атрибутам:{' '}
{Object.keys(filters.attributes).length}
</span>
)}
</div>
</div>
)}
<section
className={clsx(
'grid gap-6',
'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
)}
>
{templates.map(t => (
{filteredTemplates.map(t => (
<TemplateCard
key={t.id}
template={t}
@@ -155,7 +277,27 @@ export const TemplatesOverviewPage = () => {
/>
))}
</section>
{filteredTemplates.length === 0 && templates.length > 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="text-muted-foreground">
<p className="text-lg font-medium">Шаблоны не найдены</p>
<p className="text-sm">Попробуйте изменить фильтры поиска</p>
</div>
</div>
)}
{templates.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="text-muted-foreground">
<p className="text-lg font-medium">Пока нет шаблонов</p>
<p className="text-sm">Создайте свой первый шаблон протокола</p>
</div>
</div>
)}
</main>
</SidebarInset>
</SidebarProvider>
)
}

View File

@@ -1,21 +1,16 @@
// Типы элементов интерфейса
export type ElementType =
| 'text'
| 'select'
| 'radio'
| 'checkbox'
| 'number'
| 'textarea'
| 'date'
| 'standards'
| 'calibration-conditions'
| 'button-group'
// Импортируем ElementType из interface.ts
import type { ElementType } from '@/entitiy/element/model/interface'
export interface ElementOption {
value: string
label: string
}
// Реэкспортируем ElementType для удобства
export type { ElementType }
// Расширенный тип для ячейки с указанием листа
export interface CellTarget {
sheet: string // название листа, например "L" или "R"
@@ -64,6 +59,39 @@ export interface MergedCell {
endCol: number
}
// Определение атрибута (глобальный атрибут из API)
export interface Attribute {
id: string
name: string
is_required: boolean
created_at: string
updated_at: string
deleted_at?: string | null
}
// Детальная информация об атрибуте шаблона (из API)
export interface TemplateAttributeDetail {
attribute_id: string
attribute_name: string
is_required: boolean
value: string | null
created_at: string
updated_at: string
}
// Для совместимости со старым кодом - алиас
export type CategoryField = Attribute
export type CategoryFieldType = 'text' | 'select' | 'number' | 'textarea'
export interface CategoryFieldOption {
id: string
value: string
label: string
}
export interface CategoryFieldValue {
fieldId: string
value: string | number
}
// Типы шаблонов
export interface Template {
id: string

View File

@@ -8,16 +8,18 @@ import { useNavigate } from 'react-router-dom'
interface TemplateCardProps {
template: Template
selectionMode: boolean
selectionMode?: boolean
isSelected: boolean
onToggleSelect: (id: string) => void
onToggleSelect?: (id: string) => void
onDelete?: () => void
}
const TemplateCard: React.FC<TemplateCardProps> = ({
template,
selectionMode,
selectionMode = false,
isSelected,
onToggleSelect,
onDelete,
}) => {
const navigate = useNavigate()
@@ -29,7 +31,11 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
// console.log(template.excelFile),
<Card
aria-label={`Шаблон ${template.name}`}
onClick={selectionMode ? () => onToggleSelect(template.id) : undefined}
onClick={
selectionMode && onToggleSelect
? () => onToggleSelect(template.id)
: undefined
}
className={clsx(
'group relative shadow-sm transition-all duration-200',
selectionMode && 'hover:cursor-pointer',
@@ -47,7 +53,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
aria-label={isSelected ? 'Снять выделение' : 'Выбрать'}
onClick={e => {
e.stopPropagation()
onToggleSelect(template.id)
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',
@@ -69,6 +75,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
{template.description}
</p>
)}
<div className="mb-3 flex justify-between text-xs text-muted-foreground/80">
<span>
Создан:{' '}

View File

@@ -16,7 +16,7 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
target: 'http://192.169.0.163:8000',
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, ''),
},

View File

@@ -589,7 +589,7 @@
dependencies:
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-slot@1.2.3":
"@radix-ui/react-slot@1.2.3", "@radix-ui/react-slot@^1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz#502d6e354fc847d4169c3bc5f189de777f68cfe1"
integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==