Единый интерфейс элементов
This commit is contained in:
@@ -21,6 +21,7 @@
|
|||||||
"@radix-ui/react-radio-group": "^1.3.7",
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@radix-ui/react-toast": "^1.2.14",
|
"@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 { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { CategoryProvider } from '@/entitiy/template/model/CategoryContext'
|
||||||
import { TemplateProvider } from '@/entitiy/template/model/TemplateContext'
|
import { TemplateProvider } from '@/entitiy/template/model/TemplateContext'
|
||||||
import {
|
import {
|
||||||
setGlobalToastContext,
|
setGlobalToastContext,
|
||||||
@@ -33,26 +34,28 @@ const App: FC = () => {
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ToastInitializer />
|
<ToastInitializer />
|
||||||
<TemplateProvider>
|
<CategoryProvider>
|
||||||
<Routes>
|
<TemplateProvider>
|
||||||
<Route path="/" element={<Layout />}>
|
<Routes>
|
||||||
<Route index element={<Navigate to="/templates" replace />} />
|
<Route path="/" element={<Layout />}>
|
||||||
<Route path="templates/" element={<TemplatesOverviewPage />} />
|
<Route index element={<Navigate to="/templates" replace />} />
|
||||||
<Route
|
<Route path="templates/" element={<TemplatesOverviewPage />} />
|
||||||
path="templates/:templateId/edit"
|
<Route
|
||||||
element={<TemplateEditPage />}
|
path="templates/:templateId/edit"
|
||||||
/>
|
element={<TemplateEditPage />}
|
||||||
<Route
|
/>
|
||||||
path="templates/:templateId/elements"
|
<Route
|
||||||
element={<ElementsCreation />}
|
path="templates/:templateId/elements"
|
||||||
/>
|
element={<ElementsCreation />}
|
||||||
<Route
|
/>
|
||||||
path="templates/:templateId/protocols"
|
<Route
|
||||||
element={<ProtocolCreation />}
|
path="templates/:templateId/protocols"
|
||||||
/>
|
element={<ProtocolCreation />}
|
||||||
</Route>
|
/>
|
||||||
</Routes>
|
</Route>
|
||||||
</TemplateProvider>
|
</Routes>
|
||||||
|
</TemplateProvider>
|
||||||
|
</CategoryProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
|
|||||||
|
|
||||||
import { store } from './store'
|
import { store } from './store'
|
||||||
|
|
||||||
import { initializeElementRegistry } from '../lib/element-registry-init'
|
import { initializeElementRegistry } from '@/entitiy/element/model/interface'
|
||||||
|
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
@@ -17,5 +17,5 @@ createRoot(document.getElementById('root') as HTMLElement).render(
|
|||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<App />
|
<App />
|
||||||
</Provider>
|
</Provider>
|
||||||
</BrowserRouter>,
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Button } from '@/component/ui/button'
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
|
||||||
import { Input } from '@/component/ui/input'
|
import { Input } from '@/component/ui/input'
|
||||||
import { Label } from '@/component/ui/label'
|
import { Label } from '@/component/ui/label'
|
||||||
import { ElementDefinition } from '@/lib/element-registry'
|
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Check,
|
Check,
|
||||||
@@ -418,9 +418,10 @@ export const calibrationConditionsDefinition: ElementDefinition<
|
|||||||
CalibrationConditionsConfig,
|
CalibrationConditionsConfig,
|
||||||
CalibrationConditions
|
CalibrationConditions
|
||||||
> = {
|
> = {
|
||||||
type: 'calibration-conditions',
|
type: 'calibration_conditions',
|
||||||
label: 'Условия калибровки',
|
label: 'Условия калибровки',
|
||||||
icon: <Thermometer className="h-4 w-4" />,
|
icon: <Thermometer className="h-4 w-4" />,
|
||||||
|
version: 1,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
targetCells: [],
|
targetCells: [],
|
||||||
defaultConditions: {
|
defaultConditions: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ElementDefinition } from '@/lib/element-registry'
|
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||||
import { CheckSquare } from 'lucide-react'
|
import { CheckSquare } from 'lucide-react'
|
||||||
|
|
||||||
interface CheckboxConfig {
|
interface CheckboxConfig {
|
||||||
@@ -11,11 +11,13 @@ export const checkboxDefinition: ElementDefinition<CheckboxConfig, boolean> = {
|
|||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
label: 'Чекбокс',
|
label: 'Чекбокс',
|
||||||
icon: <CheckSquare className="h-4 w-4" />,
|
icon: <CheckSquare className="h-4 w-4" />,
|
||||||
|
version: 1,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
required: false,
|
required: false,
|
||||||
targetCells: [],
|
targetCells: [],
|
||||||
},
|
},
|
||||||
|
mapToCells: config => config.targetCells || [],
|
||||||
Editor: () => (
|
Editor: () => (
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
Дополнительные настройки отсутствуют
|
Дополнительные настройки отсутствуют
|
||||||
@@ -37,7 +39,7 @@ export const checkboxDefinition: ElementDefinition<CheckboxConfig, boolean> = {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={value || false}
|
checked={value || false}
|
||||||
onChange={(e) => onChange?.(e.target.checked)}
|
onChange={e => onChange?.(e.target.checked)}
|
||||||
required={config.required}
|
required={config.required}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Input } from '@/component/ui/input'
|
import { Input } from '@/component/ui/input'
|
||||||
import { ElementDefinition } from '@/lib/element-registry'
|
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||||
import { Calendar } from 'lucide-react'
|
import { Calendar } from 'lucide-react'
|
||||||
|
|
||||||
interface DateConfig {
|
interface DateConfig {
|
||||||
@@ -11,10 +11,12 @@ export const dateDefinition: ElementDefinition<DateConfig, string> = {
|
|||||||
type: 'date',
|
type: 'date',
|
||||||
label: 'Дата',
|
label: 'Дата',
|
||||||
icon: <Calendar className="h-4 w-4" />,
|
icon: <Calendar className="h-4 w-4" />,
|
||||||
|
version: 1,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
required: false,
|
required: false,
|
||||||
targetCells: [],
|
targetCells: [],
|
||||||
},
|
},
|
||||||
|
mapToCells: config => config.targetCells || [],
|
||||||
Editor: ({ config, onChange }) => (
|
Editor: ({ config, onChange }) => (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Input } from '@/component/ui/input'
|
import { Input } from '@/component/ui/input'
|
||||||
import { ElementDefinition } from '@/lib/element-registry'
|
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||||
import { Hash } from 'lucide-react'
|
import { Hash } from 'lucide-react'
|
||||||
|
|
||||||
interface NumberConfig {
|
interface NumberConfig {
|
||||||
@@ -12,11 +12,13 @@ export const numberDefinition: ElementDefinition<NumberConfig, number> = {
|
|||||||
type: 'number',
|
type: 'number',
|
||||||
label: 'Число',
|
label: 'Число',
|
||||||
icon: <Hash className="h-4 w-4" />,
|
icon: <Hash className="h-4 w-4" />,
|
||||||
|
version: 1,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
required: false,
|
required: false,
|
||||||
targetCells: [],
|
targetCells: [],
|
||||||
},
|
},
|
||||||
|
mapToCells: config => config.targetCells || [],
|
||||||
Editor: () => (
|
Editor: () => (
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
Дополнительные настройки отсутствуют
|
Дополнительные настройки отсутствуют
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Button } from '@/component/ui/button'
|
import { Button } from '@/component/ui/button'
|
||||||
import { Input } from '@/component/ui/input'
|
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 { ElementOption } from '@/type/template'
|
||||||
import { CheckSquare, Plus, Trash2 } from 'lucide-react'
|
import { CheckSquare, Plus, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
@@ -15,12 +15,14 @@ export const radioDefinition: ElementDefinition<RadioConfig, string> = {
|
|||||||
type: 'radio',
|
type: 'radio',
|
||||||
label: 'Радиокнопки',
|
label: 'Радиокнопки',
|
||||||
icon: <CheckSquare className="h-4 w-4" />,
|
icon: <CheckSquare className="h-4 w-4" />,
|
||||||
|
version: 1,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
required: false,
|
required: false,
|
||||||
options: [],
|
options: [],
|
||||||
targetCells: [],
|
targetCells: [],
|
||||||
},
|
},
|
||||||
|
mapToCells: config => config.targetCells || [],
|
||||||
Editor: ({ config, onChange }) => {
|
Editor: ({ config, onChange }) => {
|
||||||
const handleAddOption = () => {
|
const handleAddOption = () => {
|
||||||
onChange({
|
onChange({
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/component/ui/select'
|
} from '@/component/ui/select'
|
||||||
import { ElementDefinition } from '@/lib/element-registry'
|
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||||
import { ElementOption } from '@/type/template'
|
import { ElementOption } from '@/type/template'
|
||||||
import { ChevronDown, Plus, Trash2 } from 'lucide-react'
|
import { ChevronDown, Plus, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
@@ -22,12 +22,14 @@ export const selectDefinition: ElementDefinition<SelectConfig, string> = {
|
|||||||
type: 'select',
|
type: 'select',
|
||||||
label: 'Выпадающий список',
|
label: 'Выпадающий список',
|
||||||
icon: <ChevronDown className="h-4 w-4" />,
|
icon: <ChevronDown className="h-4 w-4" />,
|
||||||
|
version: 1,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
required: false,
|
required: false,
|
||||||
options: [],
|
options: [],
|
||||||
targetCells: [],
|
targetCells: [],
|
||||||
},
|
},
|
||||||
|
mapToCells: config => config.targetCells || [],
|
||||||
Editor: ({ config, onChange }) => {
|
Editor: ({ config, onChange }) => {
|
||||||
const handleAddOption = () => {
|
const handleAddOption = () => {
|
||||||
onChange({
|
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 { Textarea } from '@/component/ui/textarea'
|
||||||
import { ElementDefinition } from '@/lib/element-registry'
|
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||||
import { FileText } from 'lucide-react'
|
import { FileText } from 'lucide-react'
|
||||||
|
|
||||||
interface TextareaConfig {
|
interface TextareaConfig {
|
||||||
@@ -12,11 +12,13 @@ export const textareaDefinition: ElementDefinition<TextareaConfig, string> = {
|
|||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
label: 'Многострочный текст',
|
label: 'Многострочный текст',
|
||||||
icon: <FileText className="h-4 w-4" />,
|
icon: <FileText className="h-4 w-4" />,
|
||||||
|
version: 1,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
required: false,
|
required: false,
|
||||||
targetCells: [],
|
targetCells: [],
|
||||||
},
|
},
|
||||||
|
mapToCells: config => config.targetCells || [],
|
||||||
Editor: () => (
|
Editor: () => (
|
||||||
<div className="text-sm text-gray-600">
|
<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 { calibrationConditionsDefinition } from './CalibrationConditionsElement'
|
||||||
export { checkboxDefinition } from './CheckboxElement'
|
export { checkboxDefinition } from './CheckboxElement'
|
||||||
export { dateDefinition } from './DateElement'
|
export { dateDefinition } from './DateElement'
|
||||||
@@ -6,4 +7,3 @@ export { numberDefinition } from './NumberElement'
|
|||||||
export { radioDefinition } from './RadioElement'
|
export { radioDefinition } from './RadioElement'
|
||||||
export { selectDefinition } from './SelectElement'
|
export { selectDefinition } from './SelectElement'
|
||||||
export { textareaDefinition } from './TextareaElement'
|
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 { memo } from 'react'
|
||||||
import { FormulaBarProps } from '../types'
|
import { FormulaBarProps } from '../types'
|
||||||
import { getColumnLabel } from '../utils'
|
import { getColumnLabel } from '../utils'
|
||||||
@@ -144,6 +144,25 @@ export const FormulaBar = memo(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{onManualSave && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { COL_COUNT, ROW_COUNT, SHEET_NAMES, SpreadsheetProps } from '../types'
|
|||||||
import { getColumnLabel } from '../utils'
|
import { getColumnLabel } from '../utils'
|
||||||
import { CellRenderer } from './CellRenderer'
|
import { CellRenderer } from './CellRenderer'
|
||||||
|
|
||||||
|
// Создаем типизированный компонент для совместимости с React 18
|
||||||
|
const Grid = VariableSizeGrid as unknown as React.ComponentType<any>
|
||||||
|
|
||||||
// --- Spreadsheet Component ---
|
// --- Spreadsheet Component ---
|
||||||
export const Spreadsheet = ({
|
export const Spreadsheet = ({
|
||||||
sheetType,
|
sheetType,
|
||||||
@@ -274,9 +277,11 @@ export const Spreadsheet = ({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
<VariableSizeGrid
|
<Grid
|
||||||
ref={(el: VariableSizeGrid | null) => {
|
ref={(el: any) => {
|
||||||
gridRefs.current[sheetType] = el
|
if (gridRefs.current) {
|
||||||
|
gridRefs.current[sheetType] = el
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
columnCount={COL_COUNT}
|
columnCount={COL_COUNT}
|
||||||
rowCount={ROW_COUNT}
|
rowCount={ROW_COUNT}
|
||||||
@@ -287,13 +292,13 @@ export const Spreadsheet = ({
|
|||||||
itemData={itemData}
|
itemData={itemData}
|
||||||
overscanColumnCount={20}
|
overscanColumnCount={20}
|
||||||
overscanRowCount={10}
|
overscanRowCount={10}
|
||||||
onScroll={e => {
|
onScroll={(e: any) => {
|
||||||
setScrollPos({ left: e.scrollLeft, top: e.scrollTop })
|
setScrollPos({ left: e.scrollLeft, top: e.scrollTop })
|
||||||
onGridScroll(sheetType, e)
|
onGridScroll(sheetType, e)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{CellRenderer}
|
{CellRenderer}
|
||||||
</VariableSizeGrid>
|
</Grid>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||||
import { parseCellAddress } from '@/lib/cell-utils'
|
import { parseCellAddress } from '@/lib/cell-utils'
|
||||||
import { ElementDefinition } from '@/lib/element-registry'
|
|
||||||
import { CellTarget } from '@/type/template'
|
import { CellTarget } from '@/type/template'
|
||||||
import { Weight } from 'lucide-react'
|
import { Weight } from 'lucide-react'
|
||||||
import { StandardsEditor } from './StandardsEditor'
|
import { StandardsEditor } from './StandardsEditor'
|
||||||
@@ -18,6 +18,7 @@ export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
|
|||||||
type: 'standards',
|
type: 'standards',
|
||||||
label: 'Измерительные эталоны',
|
label: 'Измерительные эталоны',
|
||||||
icon: <Weight className="h-4 w-4" />,
|
icon: <Weight className="h-4 w-4" />,
|
||||||
|
version: 1,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
registryNumber: '',
|
registryNumber: '',
|
||||||
sheet: 'Report',
|
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 {
|
import {
|
||||||
getAvailableElementTypes,
|
getAvailableElementTypes,
|
||||||
getElementDefinition,
|
getElementDefinition,
|
||||||
} from '@/lib/element-registry'
|
} from '@/entitiy/element/model/interface'
|
||||||
import {
|
import {
|
||||||
autoArrangeElements,
|
autoArrangeElements,
|
||||||
findFreePosition,
|
findFreePosition,
|
||||||
@@ -109,8 +109,6 @@ const CellTargetEditor: React.FC<{
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="L">L (Левый)</SelectItem>
|
<SelectItem value="L">L (Левый)</SelectItem>
|
||||||
<SelectItem value="R">R (Правый)</SelectItem>
|
<SelectItem value="R">R (Правый)</SelectItem>
|
||||||
<SelectItem value="Report">Report</SelectItem>
|
|
||||||
<SelectItem value="Calculations">Calculations</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,7 +252,7 @@ const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'calibration-conditions':
|
case 'calibration_conditions':
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-3 py-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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'button-group':
|
case 'button_group':
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -381,7 +379,7 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
|||||||
const needsOptions =
|
const needsOptions =
|
||||||
formData.type === 'select' ||
|
formData.type === 'select' ||
|
||||||
formData.type === 'radio' ||
|
formData.type === 'radio' ||
|
||||||
formData.type === 'button-group'
|
formData.type === 'button_group'
|
||||||
|
|
||||||
// Если есть определение элемента в реестре, используем его редактор
|
// Если есть определение элемента в реестре, используем его редактор
|
||||||
if (elementDefinition && elementDefinition.Editor) {
|
if (elementDefinition && elementDefinition.Editor) {
|
||||||
@@ -395,35 +393,21 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
|||||||
value={formData.type}
|
value={formData.type}
|
||||||
onValueChange={value => {
|
onValueChange={value => {
|
||||||
const newType = value as ElementType
|
const newType = value as ElementType
|
||||||
// console.log('Element type changed to:', newType)
|
const elementDefinition = getElementDefinition(newType)
|
||||||
|
|
||||||
setFormData(prev => {
|
setFormData(prev => {
|
||||||
const updates: Partial<TemplateElement> = { type: newType }
|
const updates: Partial<TemplateElement> = {
|
||||||
|
type: newType,
|
||||||
// Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию
|
// Сбрасываем targetCells при смене типа
|
||||||
if (
|
targetCells: [],
|
||||||
newType === 'standards' ||
|
|
||||||
newType === 'calibration-conditions'
|
|
||||||
) {
|
|
||||||
// console.log(
|
|
||||||
// `${newType} type selected, getting element definition`
|
|
||||||
// )
|
|
||||||
const elementDefinition = getElementDefinition(newType)
|
|
||||||
// console.log('Element definition:', elementDefinition)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newFormData = { ...prev, ...updates }
|
// Применяем конфиг по умолчанию из определения элемента
|
||||||
// console.log('New form data:', newFormData)
|
if (elementDefinition && elementDefinition.defaultConfig) {
|
||||||
return newFormData
|
Object.assign(updates, elementDefinition.defaultConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...prev, ...updates }
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -520,40 +504,21 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
|||||||
value={formData.type}
|
value={formData.type}
|
||||||
onValueChange={value => {
|
onValueChange={value => {
|
||||||
const newType = value as ElementType
|
const newType = value as ElementType
|
||||||
// console.log('Element type changed to (fallback):', newType)
|
const elementDefinition = getElementDefinition(newType)
|
||||||
|
|
||||||
setFormData(prev => {
|
setFormData(prev => {
|
||||||
const updates: Partial<TemplateElement> = { type: newType }
|
const updates: Partial<TemplateElement> = {
|
||||||
|
type: newType,
|
||||||
// Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию
|
// Сбрасываем targetCells при смене типа
|
||||||
if (
|
targetCells: [],
|
||||||
newType === 'standards' ||
|
|
||||||
newType === 'calibration-conditions'
|
|
||||||
) {
|
|
||||||
// console.log(
|
|
||||||
// `${newType} type selected (fallback), getting element definition`
|
|
||||||
// )
|
|
||||||
const elementDefinition = getElementDefinition(newType)
|
|
||||||
// console.log(
|
|
||||||
// 'Element definition (fallback):',
|
|
||||||
// elementDefinition
|
|
||||||
// )
|
|
||||||
|
|
||||||
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
|
|
||||||
// )
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newFormData = { ...prev, ...updates }
|
// Применяем конфиг по умолчанию из определения элемента
|
||||||
// console.log('New form data (fallback):', newFormData)
|
if (elementDefinition && elementDefinition.defaultConfig) {
|
||||||
return newFormData
|
Object.assign(updates, elementDefinition.defaultConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...prev, ...updates }
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -760,23 +725,14 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddElement = () => {
|
const handleAddElement = () => {
|
||||||
// console.log('handleAddElement called with formData:', formData)
|
|
||||||
|
|
||||||
if (!formData.label) {
|
if (!formData.label) {
|
||||||
// console.log('Validation failed: missing label')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Для элементов типа "standards", "calibration-conditions" и "button-group" не требуем targetCells, так как они генерируются автоматически или не нужны
|
// Получаем определение элемента
|
||||||
if (
|
const elementDefinition = getElementDefinition(formData.type as ElementType)
|
||||||
formData.type !== 'standards' &&
|
if (!elementDefinition) {
|
||||||
formData.type !== 'calibration-conditions' &&
|
console.error(`Element definition for ${formData.type} not found`)
|
||||||
formData.type !== 'button-group' &&
|
|
||||||
!formData.targetCells?.length
|
|
||||||
) {
|
|
||||||
// console.log(
|
|
||||||
// 'Validation failed: missing targetCells for element that requires manual targetCells'
|
|
||||||
// )
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,25 +746,10 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
layoutSettings.gridSize
|
layoutSettings.gridSize
|
||||||
)
|
)
|
||||||
|
|
||||||
// Специальная обработка для элементов с автоматической генерацией targetCells
|
// Используем targetCells из формы или генерируем через mapToCells
|
||||||
let targetCells = formData.targetCells || []
|
let targetCells = formData.targetCells || []
|
||||||
if (
|
if (!targetCells.length) {
|
||||||
formData.type === 'standards' ||
|
targetCells = elementDefinition.mapToCells(formData as any) || []
|
||||||
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 = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newElement: TemplateElement = {
|
const newElement: TemplateElement = {
|
||||||
@@ -837,14 +778,20 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
const handleUpdateElement = () => {
|
const handleUpdateElement = () => {
|
||||||
if (!editingElement || !formData.label) return
|
if (!editingElement || !formData.label) return
|
||||||
|
|
||||||
// Для элементов типа "standards", "calibration-conditions" и "button-group" не требуем targetCells, так как они генерируются автоматически или не нужны
|
// Получаем определение элемента
|
||||||
if (
|
const elementDefinition = getElementDefinition(
|
||||||
formData.type !== 'standards' &&
|
editingElement.type as ElementType
|
||||||
formData.type !== 'calibration-conditions' &&
|
|
||||||
formData.type !== 'button-group' &&
|
|
||||||
!formData.targetCells?.length
|
|
||||||
)
|
)
|
||||||
|
if (!elementDefinition) {
|
||||||
|
console.error(`Element definition for ${editingElement.type} not found`)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем targetCells из формы или генерируем через mapToCells
|
||||||
|
let targetCells = formData.targetCells || []
|
||||||
|
if (!targetCells.length) {
|
||||||
|
targetCells = elementDefinition.mapToCells(formData as any) || []
|
||||||
|
}
|
||||||
|
|
||||||
const updatedData = {
|
const updatedData = {
|
||||||
...formData,
|
...formData,
|
||||||
@@ -852,6 +799,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
formData.label?.toLowerCase().replace(/\s+/g, '_') ||
|
formData.label?.toLowerCase().replace(/\s+/g, '_') ||
|
||||||
formData.name ||
|
formData.name ||
|
||||||
'',
|
'',
|
||||||
|
targetCells,
|
||||||
}
|
}
|
||||||
|
|
||||||
onElementUpdate(editingElement.id, updatedData)
|
onElementUpdate(editingElement.id, updatedData)
|
||||||
@@ -943,14 +891,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAddElement}
|
onClick={handleAddElement}
|
||||||
disabled={
|
disabled={disabled || !formData.label}
|
||||||
disabled ||
|
|
||||||
!formData.label ||
|
|
||||||
(formData.type !== 'standards' &&
|
|
||||||
formData.type !== 'calibration-conditions' &&
|
|
||||||
formData.type !== 'button-group' &&
|
|
||||||
!formData.targetCells?.length)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<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 {
|
import { Edit, Grid, Move, Trash2 } from 'lucide-react'
|
||||||
Calendar,
|
|
||||||
ChevronDown,
|
|
||||||
Droplets,
|
|
||||||
Edit,
|
|
||||||
Edit3,
|
|
||||||
Gauge,
|
|
||||||
Grid,
|
|
||||||
Move,
|
|
||||||
Radio,
|
|
||||||
Thermometer,
|
|
||||||
Trash2,
|
|
||||||
Zap,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import React, { useCallback, useMemo, useState } from 'react'
|
import React, { useCallback, useMemo, useState } from 'react'
|
||||||
import { Rnd } from 'react-rnd'
|
import { Rnd } from 'react-rnd'
|
||||||
|
import { getElementDefinition } from '../../entitiy/element/model/interface'
|
||||||
import {
|
import {
|
||||||
ElementLayout,
|
ElementLayout,
|
||||||
FormLayoutSettings,
|
FormLayoutSettings,
|
||||||
TemplateElement,
|
TemplateElement,
|
||||||
} from '../../type/template'
|
} from '../../type/template'
|
||||||
import { Button } from '../ui/button'
|
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 }> = {
|
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 },
|
textarea: { width: 160, height: 80 },
|
||||||
number: { width: 120, height: 40 },
|
number: { width: 120, height: 40 },
|
||||||
date: { width: 140, 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 },
|
radio: { width: 160, height: 80 },
|
||||||
checkbox: { width: 140, height: 40 },
|
checkbox: { width: 140, height: 40 },
|
||||||
standards: { width: 200, height: 50 },
|
standards: { width: 200, height: 50 },
|
||||||
'calibration-conditions': { width: 250, height: 60 },
|
calibration_conditions: { width: 250, height: 60 },
|
||||||
'button-group': { width: 180, height: 50 },
|
button_group: { width: 180, height: 80 },
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VisualLayoutEditorProps {
|
interface VisualLayoutEditorProps {
|
||||||
@@ -61,26 +47,6 @@ interface DraggableElementProps {
|
|||||||
gridSize: number
|
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(
|
const DraggableElement: React.FC<DraggableElementProps> = React.memo(
|
||||||
({ element, isSelected, onSelect, onUpdate, onDelete, onEdit, gridSize }) => {
|
({ element, isSelected, onSelect, onUpdate, onDelete, onEdit, gridSize }) => {
|
||||||
const layout = element.layout || {
|
const layout = element.layout || {
|
||||||
@@ -145,141 +111,16 @@ const DraggableElement: React.FC<DraggableElementProps> = React.memo(
|
|||||||
* behaviour of Rnd and keeps rendering costs minimal.
|
* behaviour of Rnd and keeps rendering costs minimal.
|
||||||
*/
|
*/
|
||||||
const previewControl = useMemo(() => {
|
const previewControl = useMemo(() => {
|
||||||
switch (element.type) {
|
const elementDefinition = getElementDefinition(element.type)
|
||||||
case 'text':
|
if (!elementDefinition || !elementDefinition.Preview) {
|
||||||
return (
|
console.error(`Element definition for ${element.type} not found`)
|
||||||
<Input
|
return null
|
||||||
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:
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none w-full">
|
||||||
|
<elementDefinition.Preview config={element as any} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}, [element])
|
}, [element])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -307,73 +148,54 @@ const DraggableElement: React.FC<DraggableElementProps> = React.memo(
|
|||||||
: 'rounded-md p-1 hover:bg-gray-50/10'
|
: '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
|
<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'
|
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
{/* Индикатор ячеек */}
|
||||||
<div className="flex items-center gap-1">
|
{element.targetCells && element.targetCells.length > 0 && (
|
||||||
<span className="truncate text-xs font-medium text-gray-600">
|
<div className="group/cells relative">
|
||||||
{element.label || element.name || `${element.type} элемент`}
|
<Grid className="h-3 w-3 cursor-help text-gray-500 opacity-60 transition-opacity hover:opacity-100" />
|
||||||
</span>
|
<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">
|
||||||
{element.required && (
|
<div className="mb-1 font-medium">Целевые ячейки:</div>
|
||||||
<span className="-mt-1.5 inline-block h-1 w-1 rounded-full bg-red-500" />
|
{element.targetCells.map((cell, i) => (
|
||||||
)}
|
<div key={i} className="font-mono">
|
||||||
</div>
|
{cell.sheet}!{cell.cell}
|
||||||
</div>
|
{cell.displayName && (
|
||||||
<div className="flex items-center gap-1">
|
<span className="ml-1 text-gray-300">
|
||||||
{/* Индикатор ячеек */}
|
({cell.displayName})
|
||||||
{element.targetCells && element.targetCells.length > 0 && (
|
</span>
|
||||||
<div className="group/cells relative">
|
)}
|
||||||
<Grid className="h-3 w-3 cursor-help text-gray-500 opacity-60 transition-opacity hover:opacity-100" />
|
</div>
|
||||||
<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>
|
||||||
)}
|
</div>
|
||||||
{onEdit && (
|
)}
|
||||||
<Button
|
{onEdit && (
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-4 w-4 cursor-pointer hover:bg-blue-100"
|
|
||||||
onClick={handleEditClick}
|
|
||||||
>
|
|
||||||
<Edit className="h-2.5 w-2.5 text-blue-600" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-4 w-4 cursor-pointer hover:bg-red-100"
|
className="h-4 w-4 cursor-pointer hover:bg-blue-100"
|
||||||
onClick={handleDeleteClick}
|
onClick={handleEditClick}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-2.5 w-2.5 text-red-600" />
|
<Edit className="h-2.5 w-2.5 text-blue-600" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-4 w-4 cursor-pointer hover:bg-red-100"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-2.5 w-2.5 text-red-600" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Real element preview */}
|
{/* Real element preview */}
|
||||||
{previewControl && (
|
{previewControl && (
|
||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">{previewControl}</div>
|
||||||
{element.label && (
|
|
||||||
<label className="pointer-events-none block text-sm font-medium text-gray-900">
|
|
||||||
{element.label}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
{previewControl}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Additional info (only visible on selection to preserve visual clarity & perf) */}
|
{/* Additional info (only visible on selection to preserve visual clarity & perf) */}
|
||||||
@@ -464,7 +286,7 @@ export const VisualLayoutEditor: React.FC<VisualLayoutEditorProps> = ({
|
|||||||
onClick={handleCanvasClick}
|
onClick={handleCanvasClick}
|
||||||
>
|
>
|
||||||
<div
|
<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 }}
|
style={{ width: canvasSize.width, height: canvasSize.height }}
|
||||||
onClick={handleCanvasClick}
|
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 { Button } from '@/component/ui/button'
|
||||||
import { Input } from '@/component/ui/input'
|
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 { ElementOption } from '@/type/template'
|
||||||
import { Plus, Square, Trash2 } from 'lucide-react'
|
import { Plus, Square, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
@@ -11,15 +11,17 @@ interface ButtonGroupConfig {
|
|||||||
targetCells: any[]
|
targetCells: any[]
|
||||||
layout?: 'horizontal' | 'vertical'
|
layout?: 'horizontal' | 'vertical'
|
||||||
buttonStyle?: 'default' | 'outline' | 'ghost'
|
buttonStyle?: 'default' | 'outline' | 'ghost'
|
||||||
|
name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buttonGroupDefinition: ElementDefinition<
|
export const buttonGroupDefinition: ElementDefinition<
|
||||||
ButtonGroupConfig,
|
ButtonGroupConfig,
|
||||||
string
|
string
|
||||||
> = {
|
> = {
|
||||||
type: 'button-group',
|
type: 'button_group',
|
||||||
label: 'Группа кнопок',
|
label: 'Группа кнопок',
|
||||||
icon: <Square className="h-4 w-4" />,
|
icon: <Square className="h-4 w-4" />,
|
||||||
|
version: 1,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
required: false,
|
required: false,
|
||||||
@@ -27,6 +29,7 @@ export const buttonGroupDefinition: ElementDefinition<
|
|||||||
targetCells: [],
|
targetCells: [],
|
||||||
layout: 'horizontal',
|
layout: 'horizontal',
|
||||||
buttonStyle: 'default',
|
buttonStyle: 'default',
|
||||||
|
name: '',
|
||||||
},
|
},
|
||||||
// Берём целевые ячейки прямо из конфига, если они заданы в редакторе
|
// Берём целевые ячейки прямо из конфига, если они заданы в редакторе
|
||||||
mapToCells: cfg => cfg.targetCells || [],
|
mapToCells: cfg => cfg.targetCells || [],
|
||||||
@@ -60,7 +63,7 @@ export const buttonGroupDefinition: ElementDefinition<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
{/* <div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Расположение кнопок</label>
|
<label className="text-sm font-medium">Расположение кнопок</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -78,7 +81,7 @@ export const buttonGroupDefinition: ElementDefinition<
|
|||||||
Вертикально
|
Вертикально
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Стиль кнопок</label>
|
<label className="text-sm font-medium">Стиль кнопок</label>
|
||||||
@@ -133,8 +136,7 @@ export const buttonGroupDefinition: ElementDefinition<
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="icon"
|
|
||||||
onClick={() => handleRemoveOption(index)}
|
onClick={() => handleRemoveOption(index)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
@@ -148,39 +150,48 @@ export const buttonGroupDefinition: ElementDefinition<
|
|||||||
},
|
},
|
||||||
Preview: ({ config }) => (
|
Preview: ({ config }) => (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
|
{config.name && (
|
||||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
<label className="text-sm font-medium text-gray-900">
|
||||||
<div
|
{config.name}
|
||||||
className={`flex ${
|
{config.required && <span className="ml-1 text-red-500">*</span>}
|
||||||
config.layout === 'vertical' ? 'flex-col' : 'flex-wrap'
|
</label>
|
||||||
} gap-1.5`}
|
)}
|
||||||
>
|
<div
|
||||||
{config.options.length > 0 ? (
|
className={`flex ${
|
||||||
config.options.map((option, index) => (
|
config.layout === 'vertical' ? 'flex-col' : 'flex-wrap'
|
||||||
<button
|
} gap-1.5`}
|
||||||
key={index}
|
>
|
||||||
className={`rounded-md border px-3 py-1.5 text-sm font-medium transition-all duration-200 ${
|
{config.options.length > 0 ? (
|
||||||
config.buttonStyle === 'outline'
|
config.options.map((option, index) => (
|
||||||
? 'border-border bg-background text-foreground hover:border-primary/30 hover:bg-primary/5'
|
<button
|
||||||
: config.buttonStyle === 'ghost'
|
key={index}
|
||||||
? 'border-transparent bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground'
|
className={`rounded-md border px-3 py-1.5 text-sm font-medium transition-all duration-200 ${
|
||||||
: 'border-primary bg-primary text-primary-foreground shadow-sm'
|
config.buttonStyle === 'outline'
|
||||||
}`}
|
? 'border-border bg-background text-foreground hover:border-primary/30 hover:bg-primary/5'
|
||||||
>
|
: config.buttonStyle === 'ghost'
|
||||||
{option.label || `Кнопка ${index + 1}`}
|
? 'border-transparent bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground'
|
||||||
</button>
|
: 'border-primary bg-primary text-primary-foreground shadow-sm'
|
||||||
))
|
}`}
|
||||||
) : (
|
>
|
||||||
<div className="rounded-md border border-dashed bg-muted/30 px-3 py-1.5 text-sm text-muted-foreground">
|
{option.label || `Кнопка ${index + 1}`}
|
||||||
{config.placeholder || 'Добавьте кнопки'}
|
</button>
|
||||||
</div>
|
))
|
||||||
)}
|
) : (
|
||||||
</div>
|
<div className="rounded-md border border-dashed bg-muted/30 px-3 py-1.5 text-sm text-muted-foreground">
|
||||||
|
{config.placeholder || 'Добавьте кнопки'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
Render: ({ config, value, onChange }) => (
|
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
|
<div
|
||||||
className={`flex ${
|
className={`flex ${
|
||||||
config.layout === 'vertical' ? 'flex-col' : 'flex-wrap'
|
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'
|
? 'border-border bg-background text-foreground hover:border-primary/30 hover:bg-primary/5'
|
||||||
: config.buttonStyle === 'ghost'
|
: config.buttonStyle === 'ghost'
|
||||||
? 'border-transparent bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground'
|
? '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}
|
{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 {
|
export interface CreateTemplateOutput {
|
||||||
id: UUID
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetTemplatesOutput {
|
export interface GetTemplatesOutput {
|
||||||
@@ -16,6 +16,10 @@ export interface GetTemplateOutput {
|
|||||||
template: ApiTemplate | null
|
template: ApiTemplate | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GetTemplateWithAttributesOutput {
|
||||||
|
template: ApiTemplateWithAttributes | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface PatchTemplateInput {
|
export interface PatchTemplateInput {
|
||||||
name?: string | null
|
name?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
@@ -36,9 +40,24 @@ export interface ApiTemplate {
|
|||||||
deleted_at?: string | null
|
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 { 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 формат для создания
|
// Преобразование внутреннего шаблона в API формат для создания
|
||||||
export function templateToCreateInput(
|
export function templateToCreateInput(
|
||||||
template: Omit<Template, 'id' | 'createdAt' | 'updatedAt'>
|
template: Omit<Template, 'id' | 'createdAt' | 'updatedAt'>
|
||||||
@@ -79,9 +117,8 @@ export function templateToCreateInput(
|
|||||||
|
|
||||||
// Преобразование внутреннего шаблона в API формат для обновления
|
// Преобразование внутреннего шаблона в API формат для обновления
|
||||||
export function templateToPatchInput(template: Template): PatchTemplateInput {
|
export function templateToPatchInput(template: Template): PatchTemplateInput {
|
||||||
// Преобразуем элементы в Record<string, any>
|
|
||||||
const elements: Record<string, any> = {}
|
const elements: Record<string, any> = {}
|
||||||
template.elements.forEach((element, index) => {
|
template.elements.forEach(element => {
|
||||||
elements[element.id] = 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 обновление шаблона
|
// API обновление шаблона
|
||||||
export async function patchTemplateApi(
|
export async function patchTemplateApi(
|
||||||
templateId: string,
|
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.status,
|
||||||
deleteMutation.error,
|
deleteMutation.error,
|
||||||
refetch,
|
refetch,
|
||||||
|
createMutation.mutateAsync,
|
||||||
|
updateMutation.mutateAsync,
|
||||||
|
deleteMutation.mutateAsync,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -141,3 +144,5 @@ export const useTemplateContext = (): TemplateContextType => {
|
|||||||
throw new Error('useTemplateContext must be used within TemplateProvider')
|
throw new Error('useTemplateContext must be used within TemplateProvider')
|
||||||
return ctx
|
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,
|
SelectValue,
|
||||||
} from '@/component/ui/select'
|
} from '@/component/ui/select'
|
||||||
import { Textarea } from '@/component/ui/textarea'
|
import { Textarea } from '@/component/ui/textarea'
|
||||||
|
import { getElementDefinition } from '@/entitiy/element/model/interface'
|
||||||
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
|
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
|
||||||
import { cellAddressToCoordinates } from '@/lib/cell-utils'
|
import { cellAddressToCoordinates } from '@/lib/cell-utils'
|
||||||
import { getElementDefinition } from '@/lib/element-registry'
|
|
||||||
import { useToast } from '@/lib/hooks/useToast'
|
import { useToast } from '@/lib/hooks/useToast'
|
||||||
import { getLatestFileForTemplate } from '@/service/fileApiService'
|
import { getLatestFileForTemplate } from '@/service/fileApiService'
|
||||||
import { Template, TemplateElement } from '@/type/template'
|
import { Template, TemplateElement } from '@/type/template'
|
||||||
@@ -118,6 +118,17 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
|
|||||||
const renderInput = () => {
|
const renderInput = () => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
|
// Используем определение элемента из реестра для рендеринга
|
||||||
|
const textDefinition = getElementDefinition('text')
|
||||||
|
if (textDefinition && textDefinition.Render) {
|
||||||
|
return (
|
||||||
|
<textDefinition.Render
|
||||||
|
config={element as any}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
placeholder={element.placeholder}
|
placeholder={element.placeholder}
|
||||||
@@ -291,9 +302,9 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'calibration-conditions':
|
case 'calibration_conditions':
|
||||||
// Используем определение элемента из реестра для рендеринга
|
// Используем определение элемента из реестра для рендеринга
|
||||||
const elementDefinition = getElementDefinition('calibration-conditions')
|
const elementDefinition = getElementDefinition('calibration_conditions')
|
||||||
if (elementDefinition && elementDefinition.Render) {
|
if (elementDefinition && elementDefinition.Render) {
|
||||||
return (
|
return (
|
||||||
<elementDefinition.Render
|
<elementDefinition.Render
|
||||||
@@ -305,9 +316,9 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|
||||||
case 'button-group':
|
case 'button_group':
|
||||||
// Используем определение элемента из реестра для рендеринга
|
// Используем определение элемента из реестра для рендеринга
|
||||||
const buttonGroupDefinition = getElementDefinition('button-group')
|
const buttonGroupDefinition = getElementDefinition('button_group')
|
||||||
if (buttonGroupDefinition && buttonGroupDefinition.Render) {
|
if (buttonGroupDefinition && buttonGroupDefinition.Render) {
|
||||||
return (
|
return (
|
||||||
<buttonGroupDefinition.Render
|
<buttonGroupDefinition.Render
|
||||||
@@ -324,37 +335,74 @@ 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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
{/* Показываем label и индикатор ячеек только если элемент не использует свой Render компонент */}
|
||||||
<div className="flex items-center gap-1">
|
{!usesElementRender && (
|
||||||
<label className="text-sm font-medium text-gray-900">
|
<div className="flex items-start justify-between gap-2">
|
||||||
{element.label}
|
<div className="flex items-center gap-1">
|
||||||
</label>
|
<label className="text-sm font-medium text-gray-900">
|
||||||
{element.required && (
|
{element.label}
|
||||||
<span className="-mt-1.5 inline-block h-1 w-1 rounded-full bg-red-500" />
|
</label>
|
||||||
|
{element.required && (
|
||||||
|
<span className="-mt-1.5 inline-block h-1 w-1 rounded-full bg-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Индикатор ячеек */}
|
||||||
|
{element.targetCells && element.targetCells.length > 0 && (
|
||||||
|
<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>
|
</div>
|
||||||
{/* Индикатор ячеек */}
|
)}
|
||||||
{element.targetCells && element.targetCells.length > 0 && (
|
|
||||||
<div className="group/cells relative">
|
{/* Для элементов с собственными Render компонентами показываем только индикатор ячеек */}
|
||||||
<Grid className="h-3 w-3 cursor-help text-gray-500 opacity-60 transition-opacity hover:opacity-100" />
|
{usesElementRender &&
|
||||||
<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">
|
element.targetCells &&
|
||||||
<div className="mb-1 font-medium">Целевые ячейки:</div>
|
element.targetCells.length > 0 && (
|
||||||
{element.targetCells.map((cell, i) => (
|
<div className="flex justify-end">
|
||||||
<div key={i} className="font-mono">
|
<div className="group/cells relative">
|
||||||
{cell.sheet}!{cell.cell}
|
<Grid className="h-3 w-3 cursor-help text-gray-500 opacity-60 transition-opacity hover:opacity-100" />
|
||||||
{cell.displayName && (
|
<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">
|
||||||
<span className="ml-1 text-gray-300">
|
<div className="mb-1 font-medium">Целевые ячейки:</div>
|
||||||
({cell.displayName})
|
{element.targetCells.map((cell, i) => (
|
||||||
</span>
|
<div key={i} className="font-mono">
|
||||||
)}
|
{cell.sheet}!{cell.cell}
|
||||||
</div>
|
{cell.displayName && (
|
||||||
))}
|
<span className="ml-1 text-gray-300">
|
||||||
|
({cell.displayName})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
{renderInput()}
|
{renderInput()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -388,7 +436,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
|
|
||||||
// Специальная обработка для условий калибровки
|
// Специальная обработка для условий калибровки
|
||||||
if (
|
if (
|
||||||
element.type === 'calibration-conditions' &&
|
element.type === 'calibration_conditions' &&
|
||||||
typeof value === 'object' &&
|
typeof value === 'object' &&
|
||||||
value !== null
|
value !== null
|
||||||
) {
|
) {
|
||||||
@@ -403,6 +451,15 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
cellValue = (value as any)[propsOrder[idx]] ?? ''
|
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 { row, col } = cellAddressToCoordinates(tc.cell)
|
||||||
const sheetName =
|
const sheetName =
|
||||||
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
|
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
|
||||||
@@ -497,7 +554,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
|
|
||||||
// 2) Объект условия калибровки -> пишем свойства по порядку
|
// 2) Объект условия калибровки -> пишем свойства по порядку
|
||||||
if (
|
if (
|
||||||
el.type === 'calibration-conditions' &&
|
el.type === 'calibration_conditions' &&
|
||||||
typeof value === 'object' &&
|
typeof value === 'object' &&
|
||||||
value !== null
|
value !== null
|
||||||
) {
|
) {
|
||||||
@@ -524,7 +581,25 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
return
|
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 => {
|
cells.forEach(tc => {
|
||||||
const { row, col } = cellAddressToCoordinates(tc.cell)
|
const { row, col } = cellAddressToCoordinates(tc.cell)
|
||||||
const sheetName =
|
const sheetName =
|
||||||
@@ -579,6 +654,33 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
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) {
|
} catch (err) {
|
||||||
console.error('Ошибка при создании протокола', err)
|
console.error('Ошибка при создании протокола', err)
|
||||||
toast.error('Ошибка при создании протокола')
|
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 { Button } from '@/component/ui/button'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -11,23 +16,42 @@ import {
|
|||||||
} from '@/component/ui/dialog'
|
} from '@/component/ui/dialog'
|
||||||
import { Input } from '@/component/ui/input'
|
import { Input } from '@/component/ui/input'
|
||||||
import { Label } from '@/component/ui/label'
|
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 { Textarea } from '@/component/ui/textarea'
|
||||||
|
import { useCategoryContext } from '@/entitiy/template/model/CategoryContext'
|
||||||
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
|
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
|
||||||
import TemplateCard from '@/widget/template/ui/TemplateCard'
|
import TemplateCard from '@/widget/template/ui/TemplateCard'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { CheckSquare, Plus, Square, Trash2 } from 'lucide-react'
|
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 = () => {
|
export const TemplatesOverviewPage = () => {
|
||||||
const { templates, deleteTemplate, addTemplate } = useTemplateContext()
|
const { templates, deleteTemplate, addTemplate } = useTemplateContext()
|
||||||
|
const { isLoading: attributesLoading } = useCategoryContext()
|
||||||
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const [selectionMode, setSelectionMode] = useState(false)
|
const [selectionMode, setSelectionMode] = useState(false)
|
||||||
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
const [newDescription, setNewDescription] = useState('')
|
const [newDescription, setNewDescription] = useState('')
|
||||||
|
const [newAttributeValues, setNewAttributeValues] = useState<
|
||||||
|
AttributeValue[]
|
||||||
|
>([])
|
||||||
|
const [filters, setFilters] = useState<TemplateFilters>({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
attributes: {},
|
||||||
|
})
|
||||||
|
|
||||||
const toggleSelect = useCallback((id: string) => {
|
const toggleSelect = useCallback((id: string) => {
|
||||||
setSelected(prev => {
|
setSelected(prev => {
|
||||||
@@ -52,6 +76,8 @@ export const TemplatesOverviewPage = () => {
|
|||||||
mergedCells: [],
|
mergedCells: [],
|
||||||
})
|
})
|
||||||
setNewName('')
|
setNewName('')
|
||||||
|
setNewDescription('')
|
||||||
|
setNewAttributeValues([])
|
||||||
setIsDialogOpen(false)
|
setIsDialogOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,99 +89,215 @@ export const TemplatesOverviewPage = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Фильтрация шаблонов
|
||||||
<main className="mx-auto max-w-7xl p-6">
|
const filteredTemplates = useMemo(() => {
|
||||||
<header className="mb-6 flex items-center justify-between">
|
return templates.filter(template => {
|
||||||
<h1 className="text-2xl font-bold">Шаблоны протоколов</h1>
|
// Фильтр по названию
|
||||||
<div className="flex gap-3">
|
if (
|
||||||
{!!selected.size && (
|
filters.name &&
|
||||||
<Button variant="destructive" onClick={removeSelected}>
|
!template.name.toLowerCase().includes(filters.name.toLowerCase())
|
||||||
<Trash2 className="mr-2 h-4 w-4" /> {selected.size}
|
) {
|
||||||
</Button>
|
return false
|
||||||
)}
|
}
|
||||||
|
|
||||||
<Button
|
// Фильтр по описанию
|
||||||
variant={selectionMode ? 'secondary' : 'outline'}
|
if (
|
||||||
onClick={toggleSelectionMode}
|
filters.description &&
|
||||||
>
|
(!template.description ||
|
||||||
{selectionMode ? (
|
!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 && (
|
||||||
<>
|
<>
|
||||||
<Square className="mr-2 h-4 w-4" /> Отменить
|
<span className="text-sm text-muted-foreground">
|
||||||
</>
|
Выбрано: {selected.size}
|
||||||
) : (
|
</span>
|
||||||
<>
|
<Button
|
||||||
<CheckSquare className="mr-2 h-4 w-4" /> Выбрать
|
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}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
{!selectionMode && (
|
||||||
<DialogTrigger asChild>
|
<>
|
||||||
<Button>
|
<Button
|
||||||
<Plus className="mr-2 h-4 w-4" /> Создать
|
size="sm"
|
||||||
</Button>
|
variant="outline"
|
||||||
</DialogTrigger>
|
onClick={toggleSelectionMode}
|
||||||
<DialogContent>
|
disabled={templates.length === 0}
|
||||||
<DialogHeader>
|
>
|
||||||
<DialogTitle>Новый шаблон</DialogTitle>
|
{selected.size === templates.length ? (
|
||||||
<DialogDescription>
|
<Square className="mr-1 h-4 w-4" />
|
||||||
Укажите имя вашего нового шаблона протокола.
|
) : (
|
||||||
</DialogDescription>
|
<CheckSquare className="mr-1 h-4 w-4" />
|
||||||
</DialogHeader>
|
)}
|
||||||
<div className="grid gap-4 py-4">
|
Выбрать
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
</Button>
|
||||||
<Label htmlFor="template-name" className="text-right">
|
|
||||||
Название
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
</Label>
|
<DialogTrigger asChild>
|
||||||
<Input
|
<Button size="sm">
|
||||||
id="template-name"
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
className="col-span-3"
|
Создать
|
||||||
value={newName}
|
</Button>
|
||||||
onChange={e => setNewName(e.target.value)}
|
</DialogTrigger>
|
||||||
placeholder="Введите название"
|
<DialogContent className="max-w-2xl">
|
||||||
/>
|
<DialogHeader>
|
||||||
</div>
|
<DialogTitle>Создать новый шаблон</DialogTitle>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<DialogDescription>
|
||||||
<Label htmlFor="template-description" className="text-right">
|
Заполните основную информацию о шаблоне
|
||||||
Описание
|
</DialogDescription>
|
||||||
</Label>
|
</DialogHeader>
|
||||||
<Textarea
|
<div className="grid gap-4 py-4">
|
||||||
id="template-description"
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
className="col-span-3"
|
<Label htmlFor="template-name" className="text-right">
|
||||||
value={newDescription}
|
Название
|
||||||
onChange={e => setNewDescription(e.target.value)}
|
</Label>
|
||||||
placeholder="Введите описание"
|
<Input
|
||||||
/>
|
id="template-name"
|
||||||
</div>
|
className="col-span-3"
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
placeholder="Введите название шаблона"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label
|
||||||
|
htmlFor="template-description"
|
||||||
|
className="text-right"
|
||||||
|
>
|
||||||
|
Описание
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="template-description"
|
||||||
|
className="col-span-3"
|
||||||
|
value={newDescription}
|
||||||
|
onChange={e => setNewDescription(e.target.value)}
|
||||||
|
placeholder="Введите описание"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<Label className="self-start pt-2 text-right">
|
||||||
|
Атрибуты
|
||||||
|
</Label>
|
||||||
|
<div className="col-span-3">
|
||||||
|
{!attributesLoading && (
|
||||||
|
<CategoryFieldsSelector
|
||||||
|
selectedValues={newAttributeValues}
|
||||||
|
onValuesChange={setNewAttributeValues}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Отмена</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button onClick={handleCreate}>Создать</Button>
|
||||||
|
</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>
|
||||||
<DialogFooter>
|
</div>
|
||||||
<DialogClose asChild>
|
)}
|
||||||
<Button variant="outline">Отмена</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button onClick={handleCreate}>Создать</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section
|
<section
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'grid gap-6',
|
'grid gap-6',
|
||||||
'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
|
'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{templates.map(t => (
|
{filteredTemplates.map(t => (
|
||||||
<TemplateCard
|
<TemplateCard
|
||||||
key={t.id}
|
key={t.id}
|
||||||
template={t}
|
template={t}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
isSelected={selected.has(t.id)}
|
isSelected={selected.has(t.id)}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
{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'
|
// Импортируем ElementType из interface.ts
|
||||||
| 'select'
|
import type { ElementType } from '@/entitiy/element/model/interface'
|
||||||
| 'radio'
|
|
||||||
| 'checkbox'
|
|
||||||
| 'number'
|
|
||||||
| 'textarea'
|
|
||||||
| 'date'
|
|
||||||
| 'standards'
|
|
||||||
| 'calibration-conditions'
|
|
||||||
| 'button-group'
|
|
||||||
|
|
||||||
export interface ElementOption {
|
export interface ElementOption {
|
||||||
value: string
|
value: string
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Реэкспортируем ElementType для удобства
|
||||||
|
export type { ElementType }
|
||||||
|
|
||||||
// Расширенный тип для ячейки с указанием листа
|
// Расширенный тип для ячейки с указанием листа
|
||||||
export interface CellTarget {
|
export interface CellTarget {
|
||||||
sheet: string // название листа, например "L" или "R"
|
sheet: string // название листа, например "L" или "R"
|
||||||
@@ -64,6 +59,39 @@ export interface MergedCell {
|
|||||||
endCol: number
|
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 {
|
export interface Template {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -8,16 +8,18 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
|
|
||||||
interface TemplateCardProps {
|
interface TemplateCardProps {
|
||||||
template: Template
|
template: Template
|
||||||
selectionMode: boolean
|
selectionMode?: boolean
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
onToggleSelect: (id: string) => void
|
onToggleSelect?: (id: string) => void
|
||||||
|
onDelete?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const TemplateCard: React.FC<TemplateCardProps> = ({
|
const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||||
template,
|
template,
|
||||||
selectionMode,
|
selectionMode = false,
|
||||||
isSelected,
|
isSelected,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
|
onDelete,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
@@ -29,7 +31,11 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
|||||||
// console.log(template.excelFile),
|
// console.log(template.excelFile),
|
||||||
<Card
|
<Card
|
||||||
aria-label={`Шаблон ${template.name}`}
|
aria-label={`Шаблон ${template.name}`}
|
||||||
onClick={selectionMode ? () => onToggleSelect(template.id) : undefined}
|
onClick={
|
||||||
|
selectionMode && onToggleSelect
|
||||||
|
? () => onToggleSelect(template.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'group relative shadow-sm transition-all duration-200',
|
'group relative shadow-sm transition-all duration-200',
|
||||||
selectionMode && 'hover:cursor-pointer',
|
selectionMode && 'hover:cursor-pointer',
|
||||||
@@ -47,7 +53,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
|||||||
aria-label={isSelected ? 'Снять выделение' : 'Выбрать'}
|
aria-label={isSelected ? 'Снять выделение' : 'Выбрать'}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onToggleSelect(template.id)
|
onToggleSelect?.(template.id)
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
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',
|
'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}
|
{template.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-3 flex justify-between text-xs text-muted-foreground/80">
|
<div className="mb-3 flex justify-between text-xs text-muted-foreground/80">
|
||||||
<span>
|
<span>
|
||||||
Создан:{' '}
|
Создан:{' '}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8000',
|
target: 'http://192.169.0.163:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: path => path.replace(/^\/api/, ''),
|
rewrite: path => path.replace(/^\/api/, ''),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -589,7 +589,7 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@radix-ui/react-primitive" "2.1.3"
|
"@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"
|
version "1.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz#502d6e354fc847d4169c3bc5f189de777f68cfe1"
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz#502d6e354fc847d4169c3bc5f189de777f68cfe1"
|
||||||
integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==
|
integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==
|
||||||
|
|||||||
Reference in New Issue
Block a user