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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,9 @@ import { COL_COUNT, ROW_COUNT, SHEET_NAMES, SpreadsheetProps } from '../types'
import { getColumnLabel } from '../utils'
import { CellRenderer } from './CellRenderer'
// Создаем типизированный компонент для совместимости с React 18
const Grid = VariableSizeGrid as unknown as React.ComponentType<any>
// --- Spreadsheet Component ---
export const Spreadsheet = ({
sheetType,
@@ -274,9 +277,11 @@ export const Spreadsheet = ({
/>
)
})()}
<VariableSizeGrid
ref={(el: VariableSizeGrid | null) => {
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>

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import {
getAvailableElementTypes,
getElementDefinition,
} from '@/lib/element-registry'
} from '@/entitiy/element/model/interface'
import {
autoArrangeElements,
findFreePosition,
@@ -109,8 +109,6 @@ const CellTargetEditor: React.FC<{
<SelectContent>
<SelectItem value="L">L (Левый)</SelectItem>
<SelectItem value="R">R (Правый)</SelectItem>
<SelectItem value="Report">Report</SelectItem>
<SelectItem value="Calculations">Calculations</SelectItem>
</SelectContent>
</Select>
</div>
@@ -254,7 +252,7 @@ const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({
</div>
)
case 'calibration-conditions':
case 'calibration_conditions':
return (
<div className="space-y-2">
<div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-3 py-2">
@@ -287,7 +285,7 @@ const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({
</div>
)
case 'button-group':
case 'button_group':
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5">
@@ -381,7 +379,7 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
const needsOptions =
formData.type === 'select' ||
formData.type === 'radio' ||
formData.type === 'button-group'
formData.type === 'button_group'
// Если есть определение элемента в реестре, используем его редактор
if (elementDefinition && elementDefinition.Editor) {
@@ -395,35 +393,21 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
value={formData.type}
onValueChange={value => {
const newType = value as ElementType
// console.log('Element type changed to:', newType)
const elementDefinition = getElementDefinition(newType)
setFormData(prev => {
const updates: Partial<TemplateElement> = { type: newType }
// Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию
if (
newType === 'standards' ||
newType === 'calibration-conditions'
) {
// console.log(
// `${newType} type selected, getting element definition`
// )
const elementDefinition = getElementDefinition(newType)
// console.log('Element definition:', elementDefinition)
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" />
Добавить элемент

View File

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

View File

@@ -1,33 +1,19 @@
import {
Calendar,
ChevronDown,
Droplets,
Edit,
Edit3,
Gauge,
Grid,
Move,
Radio,
Thermometer,
Trash2,
Zap,
} from 'lucide-react'
import { Edit, Grid, Move, Trash2 } from 'lucide-react'
import React, { useCallback, useMemo, useState } from 'react'
import { Rnd } from 'react-rnd'
import { getElementDefinition } from '../../entitiy/element/model/interface'
import {
ElementLayout,
FormLayoutSettings,
TemplateElement,
} from '../../type/template'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Textarea } from '../ui/textarea'
// Минимальные размеры для разных типов элементов.
// Если понадобится скорректировать размеры для конкретного элемента,
// достаточно изменить эту таблицу.
const ELEMENT_MIN_SIZE: Record<string, { width: number; height: number }> = {
text: { width: 120, height: 40 },
text: { width: 120, height: 80 },
textarea: { width: 160, height: 80 },
number: { width: 120, height: 40 },
date: { width: 140, height: 40 },
@@ -35,8 +21,8 @@ const ELEMENT_MIN_SIZE: Record<string, { width: number; height: number }> = {
radio: { width: 160, height: 80 },
checkbox: { width: 140, height: 40 },
standards: { width: 200, height: 50 },
'calibration-conditions': { width: 250, height: 60 },
'button-group': { width: 180, height: 50 },
calibration_conditions: { width: 250, height: 60 },
button_group: { width: 180, height: 80 },
}
interface VisualLayoutEditorProps {
@@ -61,26 +47,6 @@ interface DraggableElementProps {
gridSize: number
}
// const ELEMENT_ICONS: Record<ElementType, React.ReactNode> = {
// text: <Type className="h-4 w-4" />,
// textarea: <FileText className="h-4 w-4" />,
// number: <Hash className="h-4 w-4" />,
// date: <Calendar className="h-4 w-4" />,
// select: <ChevronDown className="h-4 w-4" />,
// radio: <CheckSquare className="h-4 w-4" />,
// checkbox: <CheckSquare className="h-4 w-4" />,
// };
// const ELEMENT_COLORS: Record<ElementType, string> = {
// text: 'bg-blue-100 border-blue-300 text-blue-800',
// textarea: 'bg-green-100 border-green-300 text-green-800',
// number: 'bg-purple-100 border-purple-300 text-purple-800',
// date: 'bg-orange-100 border-orange-300 text-orange-800',
// select: 'bg-indigo-100 border-indigo-300 text-indigo-800',
// radio: 'bg-pink-100 border-pink-300 text-pink-800',
// checkbox: 'bg-teal-100 border-teal-300 text-teal-800',
// };
const DraggableElement: React.FC<DraggableElementProps> = React.memo(
({ element, isSelected, onSelect, onUpdate, onDelete, onEdit, gridSize }) => {
const layout = element.layout || {
@@ -145,141 +111,16 @@ const DraggableElement: React.FC<DraggableElementProps> = React.memo(
* behaviour of Rnd and keeps rendering costs minimal.
*/
const previewControl = useMemo(() => {
switch (element.type) {
case 'text':
return (
<Input
readOnly
placeholder={element.placeholder || 'Введите текст'}
className="pointer-events-none w-full bg-white"
/>
)
case 'textarea':
return (
<Textarea
readOnly
rows={3}
placeholder={element.placeholder || 'Введите текст'}
className="pointer-events-none w-full resize-none bg-white"
/>
)
case 'number':
return (
<Input
type="number"
readOnly
placeholder={element.placeholder || '0'}
className="pointer-events-none w-full bg-white"
/>
)
case 'date':
return (
<Input
type="date"
readOnly
className="pointer-events-none w-full bg-white"
/>
)
case 'select':
return (
<div className="pointer-events-none w-full">
<div className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-white px-3 py-2 text-sm">
<span className="text-gray-500">
{element.placeholder || 'Выберите значение'}
</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</div>
</div>
)
case 'radio':
return (
<div className="pointer-events-none space-y-2">
{(element.options?.length
? element.options
: [{ label: 'Вариант 1' }]
).map((opt, idx) => (
<div key={idx} className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-gray-300 bg-white" />
<span className="text-sm text-gray-900">
{opt.label || `Вариант ${idx + 1}`}
</span>
</div>
))}
</div>
)
case 'checkbox':
return (
<div className="pointer-events-none flex items-center gap-2">
<div className="h-4 w-4 rounded border-2 border-gray-300 bg-white" />
<span className="text-sm text-gray-900">
{element.placeholder || element.label || 'Отметить'}
</span>
</div>
)
case 'standards':
return (
<div className="pointer-events-none w-full">
<div className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-white px-3 py-2 text-sm">
<span className="text-gray-500">
{element.placeholder || 'Выберите эталоны'}
</span>
<Calendar className="h-4 w-4 opacity-50" />
</div>
</div>
)
case 'calibration-conditions':
return (
<div className="pointer-events-none w-full">
<div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-3 py-2">
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1">
<Thermometer className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">20°C</span>
</div>
<div className="flex items-center gap-1">
<Droplets className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">65%</span>
</div>
<div className="flex items-center gap-1">
<Gauge className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">101.3кПа</span>
</div>
<div className="flex items-center gap-1">
<Zap className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">220В</span>
</div>
<div className="flex items-center gap-1">
<Radio className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">50Гц</span>
</div>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 p-0">
<Edit3 className="h-3 w-3" />
</Button>
</div>
</div>
)
case 'button-group':
return (
<div className="pointer-events-none w-full">
<div className="flex flex-wrap gap-1.5">
{(element.options?.length
? element.options
: [{ label: 'Кнопка 1' }, { label: 'Кнопка 2' }]
).map((opt, idx) => (
<button
key={idx}
className="rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-all duration-200"
>
{opt.label || `Кнопка ${idx + 1}`}
</button>
))}
</div>
</div>
)
default:
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}
>

View File

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