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