Единый интерфейс элементов
This commit is contained in:
@@ -1,214 +0,0 @@
|
||||
import { Button } from '@/component/ui/button'
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { ElementDefinition } from '@/lib/element-registry'
|
||||
import { ElementOption } from '@/type/template'
|
||||
import { Plus, Square, Trash2 } from 'lucide-react'
|
||||
|
||||
interface ButtonGroupConfig {
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
options: ElementOption[]
|
||||
targetCells: any[]
|
||||
layout?: 'horizontal' | 'vertical'
|
||||
buttonStyle?: 'default' | 'outline' | 'ghost'
|
||||
}
|
||||
|
||||
export const buttonGroupDefinition: ElementDefinition<
|
||||
ButtonGroupConfig,
|
||||
string
|
||||
> = {
|
||||
type: 'button-group',
|
||||
label: 'Группа кнопок',
|
||||
icon: <Square className="h-4 w-4" />,
|
||||
defaultConfig: {
|
||||
placeholder: '',
|
||||
required: false,
|
||||
options: [],
|
||||
targetCells: [],
|
||||
layout: 'horizontal',
|
||||
buttonStyle: 'default',
|
||||
},
|
||||
// Берём целевые ячейки прямо из конфига, если они заданы в редакторе
|
||||
mapToCells: cfg => cfg.targetCells || [],
|
||||
Editor: ({ config, onChange }) => {
|
||||
const handleAddOption = () => {
|
||||
onChange({
|
||||
...config,
|
||||
options: [...config.options, { value: '', label: '' }],
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateOption = (
|
||||
index: number,
|
||||
field: 'value' | 'label',
|
||||
value: string
|
||||
) => {
|
||||
onChange({
|
||||
...config,
|
||||
options: config.options.map((option, i) =>
|
||||
i === index ? { ...option, [field]: value } : option
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveOption = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
options: config.options.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Расположение кнопок</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={config.layout === 'horizontal' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onChange({ ...config, layout: 'horizontal' })}
|
||||
>
|
||||
Горизонтально
|
||||
</Button>
|
||||
<Button
|
||||
variant={config.layout === 'vertical' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onChange({ ...config, layout: 'vertical' })}
|
||||
>
|
||||
Вертикально
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Стиль кнопок</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={config.buttonStyle === 'default' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onChange({ ...config, buttonStyle: 'default' })}
|
||||
>
|
||||
Обычный
|
||||
</Button>
|
||||
<Button
|
||||
variant={config.buttonStyle === 'outline' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onChange({ ...config, buttonStyle: 'outline' })}
|
||||
>
|
||||
Контурный
|
||||
</Button>
|
||||
<Button
|
||||
variant={config.buttonStyle === 'ghost' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onChange({ ...config, buttonStyle: 'ghost' })}
|
||||
>
|
||||
Призрачный
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Варианты кнопок</label>
|
||||
<Button variant="outline" size="sm" onClick={handleAddOption}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Добавить кнопку
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-32 space-y-2 overflow-y-auto">
|
||||
{config.options.map((option, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Значение"
|
||||
value={option.value}
|
||||
onChange={e =>
|
||||
handleUpdateOption(index, 'value', e.target.value)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Текст кнопки"
|
||||
value={option.label}
|
||||
onChange={e =>
|
||||
handleUpdateOption(index, 'label', e.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveOption(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<div
|
||||
className={`flex ${
|
||||
config.layout === 'vertical' ? 'flex-col' : 'flex-wrap'
|
||||
} gap-1.5`}
|
||||
>
|
||||
{config.options.length > 0 ? (
|
||||
config.options.map((option, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm font-medium transition-all duration-200 ${
|
||||
config.buttonStyle === 'outline'
|
||||
? 'border-border bg-background text-foreground hover:border-primary/30 hover:bg-primary/5'
|
||||
: config.buttonStyle === 'ghost'
|
||||
? 'border-transparent bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
: 'border-primary bg-primary text-primary-foreground shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{option.label || `Кнопка ${index + 1}`}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 px-3 py-1.5 text-sm text-muted-foreground">
|
||||
{config.placeholder || 'Добавьте кнопки'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Render: ({ config, value, onChange }) => (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className={`flex ${
|
||||
config.layout === 'vertical' ? 'flex-col' : 'flex-wrap'
|
||||
} gap-1.5`}
|
||||
>
|
||||
{config.options.map((option, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onChange?.(option.value)}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm font-medium transition-all duration-200 ${
|
||||
value === option.value
|
||||
? 'border-primary bg-primary text-primary-foreground shadow-sm'
|
||||
: config.buttonStyle === 'outline'
|
||||
? 'border-border bg-background text-foreground hover:border-primary/30 hover:bg-primary/5'
|
||||
: config.buttonStyle === 'ghost'
|
||||
? 'border-transparent bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
: 'border-border bg-background text-foreground hover:border-primary/30 hover:bg-primary/5'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{config.required && !value && (
|
||||
<p className="text-sm text-red-500">
|
||||
Это поле обязательно для заполнения
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Button } from '@/component/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { Label } from '@/component/ui/label'
|
||||
import { ElementDefinition } from '@/lib/element-registry'
|
||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
@@ -418,9 +418,10 @@ export const calibrationConditionsDefinition: ElementDefinition<
|
||||
CalibrationConditionsConfig,
|
||||
CalibrationConditions
|
||||
> = {
|
||||
type: 'calibration-conditions',
|
||||
type: 'calibration_conditions',
|
||||
label: 'Условия калибровки',
|
||||
icon: <Thermometer className="h-4 w-4" />,
|
||||
version: 1,
|
||||
defaultConfig: {
|
||||
targetCells: [],
|
||||
defaultConditions: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ElementDefinition } from '@/lib/element-registry'
|
||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||
import { CheckSquare } from 'lucide-react'
|
||||
|
||||
interface CheckboxConfig {
|
||||
@@ -11,11 +11,13 @@ export const checkboxDefinition: ElementDefinition<CheckboxConfig, boolean> = {
|
||||
type: 'checkbox',
|
||||
label: 'Чекбокс',
|
||||
icon: <CheckSquare className="h-4 w-4" />,
|
||||
version: 1,
|
||||
defaultConfig: {
|
||||
placeholder: '',
|
||||
required: false,
|
||||
targetCells: [],
|
||||
},
|
||||
mapToCells: config => config.targetCells || [],
|
||||
Editor: () => (
|
||||
<div className="text-sm text-gray-600">
|
||||
Дополнительные настройки отсутствуют
|
||||
@@ -37,7 +39,7 @@ export const checkboxDefinition: ElementDefinition<CheckboxConfig, boolean> = {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value || false}
|
||||
onChange={(e) => onChange?.(e.target.checked)}
|
||||
onChange={e => onChange?.(e.target.checked)}
|
||||
required={config.required}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { ElementDefinition } from '@/lib/element-registry'
|
||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||
import { Calendar } from 'lucide-react'
|
||||
|
||||
interface DateConfig {
|
||||
@@ -11,10 +11,12 @@ export const dateDefinition: ElementDefinition<DateConfig, string> = {
|
||||
type: 'date',
|
||||
label: 'Дата',
|
||||
icon: <Calendar className="h-4 w-4" />,
|
||||
version: 1,
|
||||
defaultConfig: {
|
||||
required: false,
|
||||
targetCells: [],
|
||||
},
|
||||
mapToCells: config => config.targetCells || [],
|
||||
Editor: ({ config, onChange }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { ElementDefinition } from '@/lib/element-registry'
|
||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||
import { Hash } from 'lucide-react'
|
||||
|
||||
interface NumberConfig {
|
||||
@@ -12,11 +12,13 @@ export const numberDefinition: ElementDefinition<NumberConfig, number> = {
|
||||
type: 'number',
|
||||
label: 'Число',
|
||||
icon: <Hash className="h-4 w-4" />,
|
||||
version: 1,
|
||||
defaultConfig: {
|
||||
placeholder: '',
|
||||
required: false,
|
||||
targetCells: [],
|
||||
},
|
||||
mapToCells: config => config.targetCells || [],
|
||||
Editor: () => (
|
||||
<div className="text-sm text-gray-600">
|
||||
Дополнительные настройки отсутствуют
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from '@/component/ui/button'
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { ElementDefinition } from '@/lib/element-registry'
|
||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||
import { ElementOption } from '@/type/template'
|
||||
import { CheckSquare, Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
@@ -15,12 +15,14 @@ export const radioDefinition: ElementDefinition<RadioConfig, string> = {
|
||||
type: 'radio',
|
||||
label: 'Радиокнопки',
|
||||
icon: <CheckSquare className="h-4 w-4" />,
|
||||
version: 1,
|
||||
defaultConfig: {
|
||||
placeholder: '',
|
||||
required: false,
|
||||
options: [],
|
||||
targetCells: [],
|
||||
},
|
||||
mapToCells: config => config.targetCells || [],
|
||||
Editor: ({ config, onChange }) => {
|
||||
const handleAddOption = () => {
|
||||
onChange({
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/component/ui/select'
|
||||
import { ElementDefinition } from '@/lib/element-registry'
|
||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||
import { ElementOption } from '@/type/template'
|
||||
import { ChevronDown, Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
@@ -22,12 +22,14 @@ export const selectDefinition: ElementDefinition<SelectConfig, string> = {
|
||||
type: 'select',
|
||||
label: 'Выпадающий список',
|
||||
icon: <ChevronDown className="h-4 w-4" />,
|
||||
version: 1,
|
||||
defaultConfig: {
|
||||
placeholder: '',
|
||||
required: false,
|
||||
options: [],
|
||||
targetCells: [],
|
||||
},
|
||||
mapToCells: config => config.targetCells || [],
|
||||
Editor: ({ config, onChange }) => {
|
||||
const handleAddOption = () => {
|
||||
onChange({
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { ElementDefinition } from '@/lib/element-registry'
|
||||
import { Type } from 'lucide-react'
|
||||
|
||||
interface TextConfig {
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
targetCells: any[]
|
||||
}
|
||||
|
||||
export const textDefinition: ElementDefinition<TextConfig, string> = {
|
||||
type: 'text',
|
||||
label: 'Текстовое поле',
|
||||
icon: <Type className="h-4 w-4" />,
|
||||
defaultConfig: {
|
||||
placeholder: '',
|
||||
required: false,
|
||||
targetCells: [],
|
||||
},
|
||||
Editor: () => (
|
||||
<div className="text-sm text-gray-600">
|
||||
Дополнительные настройки отсутствуют
|
||||
</div>
|
||||
),
|
||||
Preview: ({ config }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<Input
|
||||
placeholder={config.placeholder || 'Введите текст'}
|
||||
disabled
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Render: ({ config, value, onChange }) => (
|
||||
<Input
|
||||
placeholder={config.placeholder || 'Введите текст'}
|
||||
value={value || ''}
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
required={config.required}
|
||||
/>
|
||||
),
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Textarea } from '@/component/ui/textarea'
|
||||
import { ElementDefinition } from '@/lib/element-registry'
|
||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||
import { FileText } from 'lucide-react'
|
||||
|
||||
interface TextareaConfig {
|
||||
@@ -12,11 +12,13 @@ export const textareaDefinition: ElementDefinition<TextareaConfig, string> = {
|
||||
type: 'textarea',
|
||||
label: 'Многострочный текст',
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
version: 1,
|
||||
defaultConfig: {
|
||||
placeholder: '',
|
||||
required: false,
|
||||
targetCells: [],
|
||||
},
|
||||
mapToCells: config => config.targetCells || [],
|
||||
Editor: () => (
|
||||
<div className="text-sm text-gray-600">
|
||||
Дополнительные настройки отсутствуют
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { buttonGroupDefinition } from './ButtonGroupElement'
|
||||
export { textDefinition } from '@/entitiy/element/model/implementations/TextElement'
|
||||
export { buttonGroupDefinition } from '../../entitiy/element/model/implementations/ButtonGroup'
|
||||
export { calibrationConditionsDefinition } from './CalibrationConditionsElement'
|
||||
export { checkboxDefinition } from './CheckboxElement'
|
||||
export { dateDefinition } from './DateElement'
|
||||
@@ -6,4 +7,3 @@ export { numberDefinition } from './NumberElement'
|
||||
export { radioDefinition } from './RadioElement'
|
||||
export { selectDefinition } from './SelectElement'
|
||||
export { textareaDefinition } from './TextareaElement'
|
||||
export { textDefinition } from './TextElement'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Save } from 'lucide-react'
|
||||
import { Redo2, Save, Undo2 } from 'lucide-react'
|
||||
import { memo } from 'react'
|
||||
import { FormulaBarProps } from '../types'
|
||||
import { getColumnLabel } from '../utils'
|
||||
@@ -144,6 +144,25 @@ export const FormulaBar = memo(
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Кнопки отмены и повтора */}
|
||||
{
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
disabled={true}
|
||||
className="flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Отменить (Ctrl+Z)"
|
||||
>
|
||||
<Undo2 className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
disabled={true}
|
||||
className="flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Повторить (Ctrl+Y)"
|
||||
>
|
||||
<Redo2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
{/* Кнопка ручного сохранения */}
|
||||
{onManualSave && (
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,9 @@ import { COL_COUNT, ROW_COUNT, SHEET_NAMES, SpreadsheetProps } from '../types'
|
||||
import { getColumnLabel } from '../utils'
|
||||
import { CellRenderer } from './CellRenderer'
|
||||
|
||||
// Создаем типизированный компонент для совместимости с React 18
|
||||
const Grid = VariableSizeGrid as unknown as React.ComponentType<any>
|
||||
|
||||
// --- Spreadsheet Component ---
|
||||
export const Spreadsheet = ({
|
||||
sheetType,
|
||||
@@ -274,9 +277,11 @@ export const Spreadsheet = ({
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
<VariableSizeGrid
|
||||
ref={(el: VariableSizeGrid | null) => {
|
||||
gridRefs.current[sheetType] = el
|
||||
<Grid
|
||||
ref={(el: any) => {
|
||||
if (gridRefs.current) {
|
||||
gridRefs.current[sheetType] = el
|
||||
}
|
||||
}}
|
||||
columnCount={COL_COUNT}
|
||||
rowCount={ROW_COUNT}
|
||||
@@ -287,13 +292,13 @@ export const Spreadsheet = ({
|
||||
itemData={itemData}
|
||||
overscanColumnCount={20}
|
||||
overscanRowCount={10}
|
||||
onScroll={e => {
|
||||
onScroll={(e: any) => {
|
||||
setScrollPos({ left: e.scrollLeft, top: e.scrollTop })
|
||||
onGridScroll(sheetType, e)
|
||||
}}
|
||||
>
|
||||
{CellRenderer}
|
||||
</VariableSizeGrid>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||
import { parseCellAddress } from '@/lib/cell-utils'
|
||||
import { ElementDefinition } from '@/lib/element-registry'
|
||||
import { CellTarget } from '@/type/template'
|
||||
import { Weight } from 'lucide-react'
|
||||
import { StandardsEditor } from './StandardsEditor'
|
||||
@@ -18,6 +18,7 @@ export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
|
||||
type: 'standards',
|
||||
label: 'Измерительные эталоны',
|
||||
icon: <Weight className="h-4 w-4" />,
|
||||
version: 1,
|
||||
defaultConfig: {
|
||||
registryNumber: '',
|
||||
sheet: 'Report',
|
||||
|
||||
177
src/component/TemplateManager/AutocompleteInput.tsx
Normal file
177
src/component/TemplateManager/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Button } from '@/component/ui/button'
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { Label } from '@/component/ui/label'
|
||||
import { ChevronDown, X } from 'lucide-react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface AutocompleteInputProps {
|
||||
label: string
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
suggestions: string[]
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
suggestions,
|
||||
placeholder = '',
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([])
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Фильтруем предложения на основе введенного текста
|
||||
useEffect(() => {
|
||||
if (value.trim() === '') {
|
||||
setFilteredSuggestions(suggestions)
|
||||
} else {
|
||||
const filtered = suggestions.filter(suggestion =>
|
||||
suggestion.toLowerCase().includes(value.toLowerCase())
|
||||
)
|
||||
setFilteredSuggestions(filtered)
|
||||
}
|
||||
setHighlightedIndex(-1)
|
||||
}, [value, suggestions])
|
||||
|
||||
// Закрытие выпадающего списка при клике вне компонента
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onValueChange(e.target.value)
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
onValueChange(suggestion)
|
||||
setIsOpen(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen || filteredSuggestions.length === 0) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setHighlightedIndex(prev =>
|
||||
prev < filteredSuggestions.length - 1 ? prev + 1 : 0
|
||||
)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setHighlightedIndex(prev =>
|
||||
prev > 0 ? prev - 1 : filteredSuggestions.length - 1
|
||||
)
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (highlightedIndex >= 0) {
|
||||
handleSuggestionClick(filteredSuggestions[highlightedIndex])
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
setIsOpen(false)
|
||||
inputRef.current?.blur()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const clearValue = () => {
|
||||
onValueChange('')
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="pr-16"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center space-x-1 pr-2">
|
||||
{value && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={clearValue}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 transition-transform ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Выпадающий список предложений */}
|
||||
{isOpen && filteredSuggestions.length > 0 && !disabled && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border bg-popover text-popover-foreground shadow-md"
|
||||
>
|
||||
{filteredSuggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={suggestion}
|
||||
className={`cursor-pointer px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground ${
|
||||
index === highlightedIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AutocompleteInput
|
||||
134
src/component/TemplateManager/CategoryFieldsSelector.tsx
Normal file
134
src/component/TemplateManager/CategoryFieldsSelector.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { Label } from '@/component/ui/label'
|
||||
import { useCategoryContext } from '@/entitiy/template/model/CategoryContext'
|
||||
import { Attribute } from '@/type/template'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
interface AttributeValue {
|
||||
attributeId: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
interface CategoryFieldsSelectorProps {
|
||||
selectedValues: AttributeValue[]
|
||||
onValuesChange: (values: AttributeValue[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const CategoryFieldsSelector: React.FC<CategoryFieldsSelectorProps> = ({
|
||||
selectedValues,
|
||||
onValuesChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
getRequiredAttributes,
|
||||
getOptionalAttributes,
|
||||
isLoading,
|
||||
} = useCategoryContext()
|
||||
|
||||
const requiredAttributes = getRequiredAttributes()
|
||||
const optionalAttributes = getOptionalAttributes()
|
||||
|
||||
const getAttributeValue = (attributeId: string): string => {
|
||||
const value = selectedValues.find(v => v.attributeId === attributeId)?.value
|
||||
return value?.toString() || ''
|
||||
}
|
||||
|
||||
const updateAttributeValue = (
|
||||
attributeId: string,
|
||||
value: string | number
|
||||
) => {
|
||||
const newValues = selectedValues.filter(v => v.attributeId !== attributeId)
|
||||
if (value !== '' && value !== null && value !== undefined) {
|
||||
newValues.push({ attributeId, value })
|
||||
}
|
||||
onValuesChange(newValues)
|
||||
}
|
||||
|
||||
const renderAttribute = (attribute: Attribute) => {
|
||||
const value = getAttributeValue(attribute.id)
|
||||
|
||||
const fieldProps = {
|
||||
disabled,
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={attribute.id} className="space-y-2">
|
||||
<Label htmlFor={attribute.id} className="text-sm font-medium">
|
||||
{attribute.name}
|
||||
{attribute.is_required && (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
)}
|
||||
</Label>
|
||||
|
||||
{/* Простое текстовое поле для всех атрибутов */}
|
||||
<Input
|
||||
id={attribute.id}
|
||||
value={value}
|
||||
onChange={e => updateAttributeValue(attribute.id, e.target.value)}
|
||||
placeholder={`Введите ${attribute.name.toLowerCase()}`}
|
||||
{...fieldProps}
|
||||
/>
|
||||
|
||||
{attribute.is_required && !value && (
|
||||
<p className="flex items-center gap-1 text-xs text-red-500">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Это поле обязательно для заполнения
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Загрузка атрибутов...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Обязательные атрибуты */}
|
||||
{requiredAttributes.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm font-semibold text-red-600">
|
||||
Обязательные атрибуты
|
||||
</Label>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{requiredAttributes.map(renderAttribute)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Необязательные атрибуты */}
|
||||
{optionalAttributes.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
Дополнительные атрибуты
|
||||
</Label>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{optionalAttributes.map(renderAttribute)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attributes.length === 0 && (
|
||||
<div className="py-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">Атрибуты не настроены</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CategoryFieldsSelector
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
getAvailableElementTypes,
|
||||
getElementDefinition,
|
||||
} from '@/lib/element-registry'
|
||||
} from '@/entitiy/element/model/interface'
|
||||
import {
|
||||
autoArrangeElements,
|
||||
findFreePosition,
|
||||
@@ -109,8 +109,6 @@ const CellTargetEditor: React.FC<{
|
||||
<SelectContent>
|
||||
<SelectItem value="L">L (Левый)</SelectItem>
|
||||
<SelectItem value="R">R (Правый)</SelectItem>
|
||||
<SelectItem value="Report">Report</SelectItem>
|
||||
<SelectItem value="Calculations">Calculations</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -254,7 +252,7 @@ const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'calibration-conditions':
|
||||
case 'calibration_conditions':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-3 py-2">
|
||||
@@ -287,7 +285,7 @@ const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'button-group':
|
||||
case 'button_group':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -381,7 +379,7 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
const needsOptions =
|
||||
formData.type === 'select' ||
|
||||
formData.type === 'radio' ||
|
||||
formData.type === 'button-group'
|
||||
formData.type === 'button_group'
|
||||
|
||||
// Если есть определение элемента в реестре, используем его редактор
|
||||
if (elementDefinition && elementDefinition.Editor) {
|
||||
@@ -395,35 +393,21 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
value={formData.type}
|
||||
onValueChange={value => {
|
||||
const newType = value as ElementType
|
||||
// console.log('Element type changed to:', newType)
|
||||
const elementDefinition = getElementDefinition(newType)
|
||||
|
||||
setFormData(prev => {
|
||||
const updates: Partial<TemplateElement> = { type: newType }
|
||||
|
||||
// Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию
|
||||
if (
|
||||
newType === 'standards' ||
|
||||
newType === 'calibration-conditions'
|
||||
) {
|
||||
// console.log(
|
||||
// `${newType} type selected, getting element definition`
|
||||
// )
|
||||
const elementDefinition = getElementDefinition(newType)
|
||||
// console.log('Element definition:', elementDefinition)
|
||||
|
||||
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 updates: Partial<TemplateElement> = {
|
||||
type: newType,
|
||||
// Сбрасываем targetCells при смене типа
|
||||
targetCells: [],
|
||||
}
|
||||
|
||||
const newFormData = { ...prev, ...updates }
|
||||
// console.log('New form data:', newFormData)
|
||||
return newFormData
|
||||
// Применяем конфиг по умолчанию из определения элемента
|
||||
if (elementDefinition && elementDefinition.defaultConfig) {
|
||||
Object.assign(updates, elementDefinition.defaultConfig)
|
||||
}
|
||||
|
||||
return { ...prev, ...updates }
|
||||
})
|
||||
}}
|
||||
>
|
||||
@@ -520,40 +504,21 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
value={formData.type}
|
||||
onValueChange={value => {
|
||||
const newType = value as ElementType
|
||||
// console.log('Element type changed to (fallback):', newType)
|
||||
const elementDefinition = getElementDefinition(newType)
|
||||
|
||||
setFormData(prev => {
|
||||
const updates: Partial<TemplateElement> = { type: newType }
|
||||
|
||||
// Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию
|
||||
if (
|
||||
newType === 'standards' ||
|
||||
newType === 'calibration-conditions'
|
||||
) {
|
||||
// console.log(
|
||||
// `${newType} type selected (fallback), getting element definition`
|
||||
// )
|
||||
const elementDefinition = getElementDefinition(newType)
|
||||
// console.log(
|
||||
// 'Element definition (fallback):',
|
||||
// elementDefinition
|
||||
// )
|
||||
|
||||
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 updates: Partial<TemplateElement> = {
|
||||
type: newType,
|
||||
// Сбрасываем targetCells при смене типа
|
||||
targetCells: [],
|
||||
}
|
||||
|
||||
const newFormData = { ...prev, ...updates }
|
||||
// console.log('New form data (fallback):', newFormData)
|
||||
return newFormData
|
||||
// Применяем конфиг по умолчанию из определения элемента
|
||||
if (elementDefinition && elementDefinition.defaultConfig) {
|
||||
Object.assign(updates, elementDefinition.defaultConfig)
|
||||
}
|
||||
|
||||
return { ...prev, ...updates }
|
||||
})
|
||||
}}
|
||||
>
|
||||
@@ -760,23 +725,14 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
}
|
||||
|
||||
const handleAddElement = () => {
|
||||
// console.log('handleAddElement called with formData:', formData)
|
||||
|
||||
if (!formData.label) {
|
||||
// console.log('Validation failed: missing label')
|
||||
return
|
||||
}
|
||||
|
||||
// Для элементов типа "standards", "calibration-conditions" и "button-group" не требуем targetCells, так как они генерируются автоматически или не нужны
|
||||
if (
|
||||
formData.type !== 'standards' &&
|
||||
formData.type !== 'calibration-conditions' &&
|
||||
formData.type !== 'button-group' &&
|
||||
!formData.targetCells?.length
|
||||
) {
|
||||
// console.log(
|
||||
// 'Validation failed: missing targetCells for element that requires manual targetCells'
|
||||
// )
|
||||
// Получаем определение элемента
|
||||
const elementDefinition = getElementDefinition(formData.type as ElementType)
|
||||
if (!elementDefinition) {
|
||||
console.error(`Element definition for ${formData.type} not found`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -790,25 +746,10 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
layoutSettings.gridSize
|
||||
)
|
||||
|
||||
// Специальная обработка для элементов с автоматической генерацией targetCells
|
||||
// Используем targetCells из формы или генерируем через mapToCells
|
||||
let targetCells = formData.targetCells || []
|
||||
if (
|
||||
formData.type === 'standards' ||
|
||||
formData.type === 'calibration-conditions'
|
||||
) {
|
||||
// Генерируем targetCells автоматически, если их нет
|
||||
if (!targetCells.length) {
|
||||
const elementDefinition = getElementDefinition(formData.type)
|
||||
if (elementDefinition && elementDefinition.defaultConfig) {
|
||||
const defaultConfig = elementDefinition.defaultConfig as any
|
||||
targetCells = elementDefinition.mapToCells?.(defaultConfig) || []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Для button-group не нужны targetCells, так как это UI элемент
|
||||
if (formData.type === 'button-group') {
|
||||
targetCells = []
|
||||
if (!targetCells.length) {
|
||||
targetCells = elementDefinition.mapToCells(formData as any) || []
|
||||
}
|
||||
|
||||
const newElement: TemplateElement = {
|
||||
@@ -837,14 +778,20 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
const handleUpdateElement = () => {
|
||||
if (!editingElement || !formData.label) return
|
||||
|
||||
// Для элементов типа "standards", "calibration-conditions" и "button-group" не требуем targetCells, так как они генерируются автоматически или не нужны
|
||||
if (
|
||||
formData.type !== 'standards' &&
|
||||
formData.type !== 'calibration-conditions' &&
|
||||
formData.type !== 'button-group' &&
|
||||
!formData.targetCells?.length
|
||||
// Получаем определение элемента
|
||||
const elementDefinition = getElementDefinition(
|
||||
editingElement.type as ElementType
|
||||
)
|
||||
if (!elementDefinition) {
|
||||
console.error(`Element definition for ${editingElement.type} not found`)
|
||||
return
|
||||
}
|
||||
|
||||
// Используем targetCells из формы или генерируем через mapToCells
|
||||
let targetCells = formData.targetCells || []
|
||||
if (!targetCells.length) {
|
||||
targetCells = elementDefinition.mapToCells(formData as any) || []
|
||||
}
|
||||
|
||||
const updatedData = {
|
||||
...formData,
|
||||
@@ -852,6 +799,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
formData.label?.toLowerCase().replace(/\s+/g, '_') ||
|
||||
formData.name ||
|
||||
'',
|
||||
targetCells,
|
||||
}
|
||||
|
||||
onElementUpdate(editingElement.id, updatedData)
|
||||
@@ -943,14 +891,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddElement}
|
||||
disabled={
|
||||
disabled ||
|
||||
!formData.label ||
|
||||
(formData.type !== 'standards' &&
|
||||
formData.type !== 'calibration-conditions' &&
|
||||
formData.type !== 'button-group' &&
|
||||
!formData.targetCells?.length)
|
||||
}
|
||||
disabled={disabled || !formData.label}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Добавить элемент
|
||||
|
||||
225
src/component/TemplateManager/TemplateFilterSidebar.tsx
Normal file
225
src/component/TemplateManager/TemplateFilterSidebar.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { AutocompleteInput } from '@/component/TemplateManager/AutocompleteInput'
|
||||
import { Button } from '@/component/ui/button'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarSeparator,
|
||||
} from '@/component/ui/sidebar'
|
||||
import { useCategoryContext } from '@/entitiy/template/model/CategoryContext'
|
||||
import { Template } from '@/type/template'
|
||||
import { Filter, X } from 'lucide-react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { Label } from '../ui/label'
|
||||
|
||||
export interface TemplateFilters {
|
||||
name: string
|
||||
description: string
|
||||
attributes: { [attributeId: string]: string } // attributeId -> value для фильтрации
|
||||
}
|
||||
|
||||
interface TemplateFilterSidebarProps {
|
||||
templates: Template[]
|
||||
filters: TemplateFilters
|
||||
onFiltersChange: (filters: TemplateFilters) => void
|
||||
}
|
||||
|
||||
export const TemplateFilterSidebar: React.FC<TemplateFilterSidebarProps> = ({
|
||||
templates,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
}) => {
|
||||
const { attributes = [], isLoading: attributesLoading } = useCategoryContext()
|
||||
|
||||
// Получаем уникальные значения для автокомплита
|
||||
const nameSuggestions = useMemo(() => {
|
||||
const names = templates.map(t => t.name).filter(Boolean)
|
||||
return [...new Set(names)].sort()
|
||||
}, [templates])
|
||||
|
||||
const descriptionSuggestions = useMemo(() => {
|
||||
const descriptions = templates
|
||||
.map(t => t.description)
|
||||
.filter(Boolean) as string[]
|
||||
return [...new Set(descriptions)].sort()
|
||||
}, [templates])
|
||||
|
||||
// Обработчики изменения фильтров
|
||||
const updateFilters = (updates: Partial<TemplateFilters>) => {
|
||||
onFiltersChange({ ...filters, ...updates })
|
||||
}
|
||||
|
||||
const updateAttributeFilter = (attributeId: string, value: string) => {
|
||||
const newAttributes = { ...filters.attributes }
|
||||
if (value) {
|
||||
newAttributes[attributeId] = value
|
||||
} else {
|
||||
delete newAttributes[attributeId]
|
||||
}
|
||||
updateFilters({ attributes: newAttributes })
|
||||
}
|
||||
|
||||
const clearAllFilters = () => {
|
||||
onFiltersChange({
|
||||
name: '',
|
||||
description: '',
|
||||
attributes: {},
|
||||
})
|
||||
}
|
||||
|
||||
const hasActiveFilters =
|
||||
filters.name ||
|
||||
filters.description ||
|
||||
Object.keys(filters.attributes).length > 0
|
||||
|
||||
const getAttributeName = (attributeId: string) => {
|
||||
const attribute = attributes.find(a => a.id === attributeId)
|
||||
return attribute?.name || 'Неизвестный атрибут'
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span className="font-semibold">Фильтры</span>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllFilters}
|
||||
className="h-auto p-1 text-xs"
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Очистить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
{/* Фильтр по названию */}
|
||||
<SidebarGroup>
|
||||
<Label className="text-sm font-medium">Поиск по названию</Label>
|
||||
<SidebarGroupContent>
|
||||
<AutocompleteInput
|
||||
label=""
|
||||
value={filters.name}
|
||||
onValueChange={value => updateFilters({ name: value })}
|
||||
suggestions={nameSuggestions}
|
||||
placeholder="Введите название"
|
||||
/>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
{/* Фильтр по описанию */}
|
||||
<SidebarGroup>
|
||||
<Label className="text-sm font-medium">Поиск по описанию</Label>
|
||||
<SidebarGroupContent>
|
||||
<AutocompleteInput
|
||||
label=""
|
||||
value={filters.description}
|
||||
onValueChange={value => updateFilters({ description: value })}
|
||||
suggestions={descriptionSuggestions}
|
||||
placeholder="Введите описание"
|
||||
/>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
{/* Активные фильтры по атрибутам */}
|
||||
{Object.keys(filters.attributes).length > 0 && (
|
||||
<>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Активные фильтры</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(filters.attributes).map(
|
||||
([attributeId, value]) => (
|
||||
<div
|
||||
key={attributeId}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{getAttributeName(attributeId)}:
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">{value}</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
updateAttributeFilter(attributeId, '')
|
||||
}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Фильтры по атрибутам */}
|
||||
{!attributesLoading && attributes.length > 0 && (
|
||||
<SidebarGroup>
|
||||
{/* <SidebarGroupLabel>Фильтры по атрибутам</SidebarGroupLabel> */}
|
||||
<SidebarGroupContent>
|
||||
<div className="space-y-3">
|
||||
{attributes.map(attribute => (
|
||||
<div key={attribute.id}>
|
||||
<AutocompleteInput
|
||||
label={attribute.name}
|
||||
value={filters.attributes[attribute.id] || ''}
|
||||
onValueChange={value =>
|
||||
updateAttributeFilter(attribute.id, value)
|
||||
}
|
||||
suggestions={[]} // Пока без автодополнения
|
||||
placeholder={`Фильтр по ${attribute.name.toLowerCase()}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{attributesLoading && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Атрибуты</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Загрузка атрибутов...
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{!attributesLoading && attributes.length === 0 && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Атрибуты</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Атрибуты не настроены
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
export default TemplateFilterSidebar
|
||||
@@ -1,33 +1,19 @@
|
||||
import {
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
Droplets,
|
||||
Edit,
|
||||
Edit3,
|
||||
Gauge,
|
||||
Grid,
|
||||
Move,
|
||||
Radio,
|
||||
Thermometer,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { Edit, Grid, Move, Trash2 } from 'lucide-react'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { Rnd } from 'react-rnd'
|
||||
import { getElementDefinition } from '../../entitiy/element/model/interface'
|
||||
import {
|
||||
ElementLayout,
|
||||
FormLayoutSettings,
|
||||
TemplateElement,
|
||||
} from '../../type/template'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { Textarea } from '../ui/textarea'
|
||||
|
||||
// Минимальные размеры для разных типов элементов.
|
||||
// Если понадобится скорректировать размеры для конкретного элемента,
|
||||
// достаточно изменить эту таблицу.
|
||||
const ELEMENT_MIN_SIZE: Record<string, { width: number; height: number }> = {
|
||||
text: { width: 120, height: 40 },
|
||||
text: { width: 120, height: 80 },
|
||||
textarea: { width: 160, height: 80 },
|
||||
number: { width: 120, height: 40 },
|
||||
date: { width: 140, height: 40 },
|
||||
@@ -35,8 +21,8 @@ const ELEMENT_MIN_SIZE: Record<string, { width: number; height: number }> = {
|
||||
radio: { width: 160, height: 80 },
|
||||
checkbox: { width: 140, height: 40 },
|
||||
standards: { width: 200, height: 50 },
|
||||
'calibration-conditions': { width: 250, height: 60 },
|
||||
'button-group': { width: 180, height: 50 },
|
||||
calibration_conditions: { width: 250, height: 60 },
|
||||
button_group: { width: 180, height: 80 },
|
||||
}
|
||||
|
||||
interface VisualLayoutEditorProps {
|
||||
@@ -61,26 +47,6 @@ interface DraggableElementProps {
|
||||
gridSize: number
|
||||
}
|
||||
|
||||
// const ELEMENT_ICONS: Record<ElementType, React.ReactNode> = {
|
||||
// text: <Type className="h-4 w-4" />,
|
||||
// textarea: <FileText className="h-4 w-4" />,
|
||||
// number: <Hash className="h-4 w-4" />,
|
||||
// date: <Calendar className="h-4 w-4" />,
|
||||
// select: <ChevronDown className="h-4 w-4" />,
|
||||
// radio: <CheckSquare className="h-4 w-4" />,
|
||||
// checkbox: <CheckSquare className="h-4 w-4" />,
|
||||
// };
|
||||
|
||||
// const ELEMENT_COLORS: Record<ElementType, string> = {
|
||||
// text: 'bg-blue-100 border-blue-300 text-blue-800',
|
||||
// textarea: 'bg-green-100 border-green-300 text-green-800',
|
||||
// number: 'bg-purple-100 border-purple-300 text-purple-800',
|
||||
// date: 'bg-orange-100 border-orange-300 text-orange-800',
|
||||
// select: 'bg-indigo-100 border-indigo-300 text-indigo-800',
|
||||
// radio: 'bg-pink-100 border-pink-300 text-pink-800',
|
||||
// checkbox: 'bg-teal-100 border-teal-300 text-teal-800',
|
||||
// };
|
||||
|
||||
const DraggableElement: React.FC<DraggableElementProps> = React.memo(
|
||||
({ element, isSelected, onSelect, onUpdate, onDelete, onEdit, gridSize }) => {
|
||||
const layout = element.layout || {
|
||||
@@ -145,141 +111,16 @@ const DraggableElement: React.FC<DraggableElementProps> = React.memo(
|
||||
* behaviour of Rnd and keeps rendering costs minimal.
|
||||
*/
|
||||
const previewControl = useMemo(() => {
|
||||
switch (element.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<Input
|
||||
readOnly
|
||||
placeholder={element.placeholder || 'Введите текст'}
|
||||
className="pointer-events-none w-full bg-white"
|
||||
/>
|
||||
)
|
||||
case 'textarea':
|
||||
return (
|
||||
<Textarea
|
||||
readOnly
|
||||
rows={3}
|
||||
placeholder={element.placeholder || 'Введите текст'}
|
||||
className="pointer-events-none w-full resize-none bg-white"
|
||||
/>
|
||||
)
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
readOnly
|
||||
placeholder={element.placeholder || '0'}
|
||||
className="pointer-events-none w-full bg-white"
|
||||
/>
|
||||
)
|
||||
case 'date':
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
readOnly
|
||||
className="pointer-events-none w-full bg-white"
|
||||
/>
|
||||
)
|
||||
case 'select':
|
||||
return (
|
||||
<div className="pointer-events-none w-full">
|
||||
<div className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-white px-3 py-2 text-sm">
|
||||
<span className="text-gray-500">
|
||||
{element.placeholder || 'Выберите значение'}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 'radio':
|
||||
return (
|
||||
<div className="pointer-events-none space-y-2">
|
||||
{(element.options?.length
|
||||
? element.options
|
||||
: [{ label: 'Вариант 1' }]
|
||||
).map((opt, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full border-2 border-gray-300 bg-white" />
|
||||
<span className="text-sm text-gray-900">
|
||||
{opt.label || `Вариант ${idx + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
case 'checkbox':
|
||||
return (
|
||||
<div className="pointer-events-none flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded border-2 border-gray-300 bg-white" />
|
||||
<span className="text-sm text-gray-900">
|
||||
{element.placeholder || element.label || 'Отметить'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
case 'standards':
|
||||
return (
|
||||
<div className="pointer-events-none w-full">
|
||||
<div className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-white px-3 py-2 text-sm">
|
||||
<span className="text-gray-500">
|
||||
{element.placeholder || 'Выберите эталоны'}
|
||||
</span>
|
||||
<Calendar className="h-4 w-4 opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 'calibration-conditions':
|
||||
return (
|
||||
<div className="pointer-events-none w-full">
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-3 py-2">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Thermometer className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">20°C</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Droplets className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">65%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Gauge className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">101.3кПа</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">220В</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Radio className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">50Гц</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0">
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 'button-group':
|
||||
return (
|
||||
<div className="pointer-events-none w-full">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(element.options?.length
|
||||
? element.options
|
||||
: [{ label: 'Кнопка 1' }, { label: 'Кнопка 2' }]
|
||||
).map((opt, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-all duration-200"
|
||||
>
|
||||
{opt.label || `Кнопка ${idx + 1}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
const elementDefinition = getElementDefinition(element.type)
|
||||
if (!elementDefinition || !elementDefinition.Preview) {
|
||||
console.error(`Element definition for ${element.type} not found`)
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="pointer-events-none w-full">
|
||||
<elementDefinition.Preview config={element as any} />
|
||||
</div>
|
||||
)
|
||||
}, [element])
|
||||
|
||||
return (
|
||||
@@ -307,73 +148,54 @@ const DraggableElement: React.FC<DraggableElementProps> = React.memo(
|
||||
: 'rounded-md p-1 hover:bg-gray-50/10'
|
||||
}`}
|
||||
>
|
||||
{/* Label and controls (only visible when selected or hovered) */}
|
||||
{/* Controls (only visible when selected or hovered) */}
|
||||
<div
|
||||
className={`mb-1 flex items-center justify-between ${
|
||||
className={`absolute right-1 top-1 flex items-center gap-1 ${
|
||||
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate text-xs font-medium text-gray-600">
|
||||
{element.label || element.name || `${element.type} элемент`}
|
||||
</span>
|
||||
{element.required && (
|
||||
<span className="-mt-1.5 inline-block h-1 w-1 rounded-full bg-red-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Индикатор ячеек */}
|
||||
{element.targetCells && element.targetCells.length > 0 && (
|
||||
<div className="group/cells relative">
|
||||
<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>
|
||||
{/* Индикатор ячеек */}
|
||||
{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>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 cursor-pointer hover:bg-red-100"
|
||||
onClick={handleDeleteClick}
|
||||
className="h-4 w-4 cursor-pointer hover:bg-blue-100"
|
||||
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>
|
||||
</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>
|
||||
|
||||
{/* Real element preview */}
|
||||
{previewControl && (
|
||||
<div className="flex-1 space-y-1">
|
||||
{element.label && (
|
||||
<label className="pointer-events-none block text-sm font-medium text-gray-900">
|
||||
{element.label}
|
||||
</label>
|
||||
)}
|
||||
{previewControl}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">{previewControl}</div>
|
||||
)}
|
||||
|
||||
{/* Additional info (only visible on selection to preserve visual clarity & perf) */}
|
||||
@@ -464,7 +286,7 @@ export const VisualLayoutEditor: React.FC<VisualLayoutEditorProps> = ({
|
||||
onClick={handleCanvasClick}
|
||||
>
|
||||
<div
|
||||
className="relative mx-auto my-8 bg-white shadow-lg"
|
||||
className="relative mx-auto my-8 select-none bg-white shadow-lg"
|
||||
style={{ width: canvasSize.width, height: canvasSize.height }}
|
||||
onClick={handleCanvasClick}
|
||||
>
|
||||
|
||||
416
src/component/ui/sidebar.tsx
Normal file
416
src/component/ui/sidebar.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { PanelLeftIcon } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/component/ui/button'
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { Separator } from '@/component/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = '16rem'
|
||||
const SIDEBAR_WIDTH_MOBILE = '18rem'
|
||||
const SIDEBAR_WIDTH_ICON = '3rem'
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: 'expanded' | 'collapsed'
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider.')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
// Simple mobile hook implementation
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
}
|
||||
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
return isMobile
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === 'function' ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? 'expanded' : 'collapsed'
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = 'left',
|
||||
variant = 'sidebar',
|
||||
collapsible = 'offcanvas',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
side?: 'left' | 'right'
|
||||
variant?: 'sidebar' | 'floating' | 'inset'
|
||||
collapsible?: 'offcanvas' | 'icon' | 'none'
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === 'none') {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
'bg-sidebar text-sidebar-foreground flex h-full w-[--sidebar-width] flex-col',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/50"
|
||||
onClick={() => setOpenMobile(false)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 w-[--sidebar-width] bg-background p-4 text-foreground shadow-lg transition-transform duration-300',
|
||||
openMobile ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="text-sidebar-foreground group peer hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === 'collapsed' ? collapsible : ''}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
'relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear',
|
||||
'group-data-[collapsible=offcanvas]:w-0',
|
||||
'group-data-[side=right]:rotate-180',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+1rem)]'
|
||||
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
'fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+1rem+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex h-full w-full flex-col border-r bg-background group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-7', className)}
|
||||
onClick={event => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
'relative flex w-full flex-1 flex-col bg-background',
|
||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn('h-8 w-full bg-background shadow-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn('mx-2 w-auto bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'div'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
'outline-hidden flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-foreground/70 ring-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn('w-full text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
Reference in New Issue
Block a user