ручной рефакторинг

This commit is contained in:
2025-07-28 17:03:01 +03:00
parent d8e6dcd7d5
commit b98dfeef20
55 changed files with 844 additions and 1416 deletions

View File

@@ -0,0 +1,238 @@
import { Button } from '@/component/ui/button'
import { Input } from '@/component/ui/input'
import { ElementDefinition } from '@/entity/element/model/interface'
import { CellsBadge } from '@/entity/element/ui/cellsBadge'
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'
name?: string
}
export const buttonGroupDefinition: ElementDefinition<
ButtonGroupConfig,
string
> = {
type: 'button_group',
label: 'Группа кнопок',
icon: <Square className="h-4 w-4" />,
version: 1,
defaultConfig: {
placeholder: '',
required: false,
options: [],
targetCells: [],
layout: 'horizontal',
buttonStyle: 'default',
name: '',
},
// Берём целевые ячейки прямо из конфига, если они заданы в редакторе
mapToCells: cfg => cfg.targetCells || [],
mapToCellValues: (config, value) => {
const cells = config.targetCells || []
// Находим option по value чтобы получить label
const selectedOption = config.options?.find(opt => opt.value === value)
const cellValue = selectedOption ? selectedOption.label : value || ''
return cells.map(target => ({ target, value: cellValue }))
},
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="ghost"
onClick={() => handleRemoveOption(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
</div>
)
},
Preview: ({ config }) => (
<div className="space-y-3">
{config.name && (
<label className="text-sm font-medium text-gray-900">
{config.name}
{config.required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<div
className={`flex ${
config.layout === 'vertical' ? 'flex-col' : 'flex-wrap'
} 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>
),
Render: ({ config, value, onChange }) => (
<div className="relative space-y-3">
{/* Бейдж ячеек */}
<CellsBadge targetCells={config.targetCells} />
{config.name && (
<label className="text-sm font-medium text-gray-900">
{config.name}
{config.required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<div
className={`flex ${
config.layout === 'vertical' ? 'flex-col' : 'flex-wrap'
} 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-primary bg-primary text-primary-foreground shadow-sm'
}`}
>
{option.label}
</button>
))}
</div>
{config.required && !value && (
<p className="text-sm text-red-500">
Это поле обязательно для заполнения
</p>
)}
</div>
),
}

View File

@@ -0,0 +1,229 @@
import { Button } from '@/component/ui/button'
import { Input } from '@/component/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/component/ui/select'
import { ElementDefinition } from '@/entity/element/model/interface'
import { CellsBadge } from '@/entity/element/ui/cellsBadge'
import { useToast } from '@/lib/hooks/useToast'
import { ElementOption } from '@/type/template'
import { ChevronDown, Plus, Trash2 } from 'lucide-react'
interface SelectConfig {
placeholder?: string
required?: boolean
options: ElementOption[]
targetCells: any[]
}
export const selectDefinition: ElementDefinition<SelectConfig, string> = {
type: 'select',
label: 'Выпадающий список',
icon: <ChevronDown className="h-4 w-4" />,
version: 1,
defaultConfig: {
placeholder: '',
required: false,
options: [],
targetCells: [],
},
getInitialValue: config => {
// Проверяем доступность localStorage (может не быть в SSR)
if (typeof window === 'undefined' || !window.localStorage) {
return ''
}
const elementId = (config as any).id || config.placeholder || 'default'
const storageKey = `select_${elementId}`
const value = localStorage.getItem(storageKey) || ''
// Добавляем отладочную информацию
console.log('SelectElement getInitialValue:', {
elementId,
storageKey,
value,
config,
})
return value
},
mapToCells: config => config.targetCells || [],
mapToCellValues: (config, value) => {
const cells = config.targetCells || []
return cells.map(target => ({ target, value: value || '' }))
},
Editor: ({ config, onChange }) => {
const { warning } = useToast()
const handleAddOption = () => {
onChange({
...config,
options: [...config.options, { value: '', label: '' }],
})
}
const handleUpdateOption = (
index: number,
field: 'value' | 'label',
value: string
) => {
// Проверяем дублирование только для подписей (label)
if (field === 'label' && value.trim() !== '') {
const isDuplicate = config.options.some(
(option, i) =>
i !== index &&
option.label.trim().toLowerCase() === value.trim().toLowerCase()
)
if (isDuplicate) {
warning(`Такая подпись уже существует: "${value.trim()}"`)
return
}
}
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">
<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) => {
const isDuplicateLabel = config.options.some(
(otherOption, i) =>
i !== index &&
option.label.trim() !== '' &&
otherOption.label.trim().toLowerCase() ===
option.label.trim().toLowerCase()
)
// Убираем проверку дублирования для значений
const isDuplicateValue = false
return (
<div key={index} className="flex gap-2">
<div className="flex-1">
<Input
placeholder="Значение"
value={option.value}
onChange={e =>
handleUpdateOption(index, 'value', e.target.value)
}
className={isDuplicateValue ? 'border-red-500' : ''}
/>
{isDuplicateValue && (
<p className="mt-1 text-xs text-red-500">
Это значение уже используется
</p>
)}
</div>
<div className="flex-1">
<Input
placeholder="Подпись"
value={option.label}
onChange={e =>
handleUpdateOption(index, 'label', e.target.value)
}
className={isDuplicateLabel ? 'border-red-500' : ''}
/>
{isDuplicateLabel && (
<p className="mt-1 text-xs text-red-500">
Такая подпись уже существует
</p>
)}
</div>
<Button
variant="outline"
size="icon"
onClick={() => handleRemoveOption(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
})}
</div>
</div>
</div>
)
},
Preview: ({ config }) => (
<Select>
<SelectTrigger className="w-full min-w-32">
<SelectValue placeholder={config.placeholder || 'Выберите значение'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{config.options
.filter(option => option.value.trim() !== '')
.map((option, index) => (
<SelectItem key={index} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
),
Render: ({ config, value, onChange }) => {
const elementId = (config as any).id || config.placeholder || 'default'
const storageKey = `select_${elementId}`
const handleValueChange = (newValue: string) => {
localStorage.setItem(storageKey, newValue)
onChange?.(newValue)
}
// Если значение не установлено, пытаемся загрузить из localStorage
const currentValue = value || localStorage.getItem(storageKey) || ''
return (
<div className="relative">
{/* Бейдж ячеек */}
<CellsBadge targetCells={config.targetCells} />
<Select value={currentValue} onValueChange={handleValueChange}>
<SelectTrigger className="w-full min-w-32">
<SelectValue
placeholder={config.placeholder || 'Выберите значение'}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{config.options
.filter(option => option.value.trim() !== '')
.map((option, index) => (
<SelectItem key={index} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
)
},
}

View File

@@ -0,0 +1,118 @@
import { Button } from '@/component/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/component/ui/select'
import { Info, Settings } from 'lucide-react'
import { useState } from 'react'
import { StandardsSelector } from './StandardsSelector'
import { StandardsConfig } from './definition'
interface StandardsEditorProps {
config: StandardsConfig
onChange: (config: StandardsConfig) => void
}
export const StandardsEditor: React.FC<StandardsEditorProps> = ({
config,
onChange,
}) => {
const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false)
// Обеспечиваем значения по умолчанию
const safeConfig = {
maxItems: config.maxItems || 7,
targetCells: config.targetCells || [],
selectedStandards: config.selectedStandards || [],
}
const updateConfig = (updates: Partial<StandardsConfig>) => {
onChange({ ...safeConfig, ...updates })
}
return (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Максимум эталонов</label>
<Select
value={safeConfig.maxItems.toString()}
onValueChange={value => updateConfig({ maxItems: parseInt(value) })}
>
<SelectTrigger className="h-8 w-16">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3</SelectItem>
<SelectItem value="7">7</SelectItem>
<SelectItem value="9">9</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="12">12</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-gray-500">
Максимальное количество эталонов для выбора (0-12)
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Предустановленные эталоны</label>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsStandardsDialogOpen(true)}
className="flex-1"
>
<Settings className="mr-2 h-4 w-4" />
{safeConfig.selectedStandards.length > 0
? `Выбрано: ${safeConfig.selectedStandards.length}`
: 'Выбрать эталоны'}
</Button>
{safeConfig.selectedStandards.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => updateConfig({ selectedStandards: [] })}
>
Очистить
</Button>
)}
</div>
<p className="text-xs text-gray-500">
Эталоны, которые будут выбраны по умолчанию при создании протокола
</p>
</div>
<div className="rounded-lg bg-blue-50 p-3">
<div className="flex items-start gap-2">
<Info className="mt-0.5 h-4 w-4 text-blue-600" />
<div className="text-sm">
<p className="font-medium text-blue-900">Как это работает:</p>
<ul className="mt-1 list-disc space-y-1 pl-4 text-blue-800">
<li>Выберите максимальное количество эталонов</li>
<li>Настройте предустановленные эталоны (опционально)</li>
<li>При создании протокола пользователь сможет изменить выбор</li>
</ul>
</div>
</div>
</div>
<StandardsSelector
isOpen={isStandardsDialogOpen}
onClose={() => setIsStandardsDialogOpen(false)}
value={safeConfig.selectedStandards}
onChange={standardIds => {
updateConfig({ selectedStandards: standardIds })
}}
maxItems={safeConfig.maxItems}
/>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { Button } from '@/component/ui/button'
import { Label } from '@/component/ui/label'
import { FileText, Settings } from 'lucide-react'
import { StandardsConfig } from './definition'
interface StandardsPreviewProps {
config: StandardsConfig
}
const StandardSkeleton = ({ index }: { index: number }) => (
<div className="flex items-center gap-2 rounded-md bg-muted/30 p-2 text-sm">
<div className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/10">
<span className="text-xs font-medium text-primary">{index + 1}</span>
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 h-3 w-3/4 rounded bg-muted"></div>
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<FileText className="h-2.5 w-2.5" />
<div className="h-2 w-16 rounded bg-muted"></div>
</div>
</div>
<div className="h-5 w-12 shrink-0 rounded bg-muted"></div>
</div>
)
export const StandardsPreview: React.FC<StandardsPreviewProps> = ({
config,
}) => {
const skeletonCount = config.maxItems || 7
return (
<div className="space-y-2">
<div>
<div className="mb-2 flex items-center justify-between">
<Label className="text-sm font-medium">Эталоны</Label>
<Button variant="outline" size="sm" disabled className="h-7 px-2">
<Settings className="mr-1 h-3 w-3" />
Настроить
</Button>
</div>
<div className="space-y-2">
<div className="space-y-1">
{Array.from({ length: skeletonCount }, (_, index) => (
<StandardSkeleton key={index} index={index} />
))}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,570 @@
import { Badge } from '@/component/ui/badge'
import { Button } from '@/component/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/component/ui/dialog'
import { Input } from '@/component/ui/input'
import { Separator } from '@/component/ui/separator'
import { useToast } from '@/lib/hooks/useToast'
import {
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
Check,
ExternalLink,
FileText,
GripVertical,
Search,
Settings,
X,
} from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useStandards } from './hooks'
import { Standard } from './types'
// Компонент для сортируемого элемента эталона
interface SortableStandardItemProps {
standard: Standard
onRemove: (standardId: string) => void
isDragActive: boolean
}
function SortableStandardItem({
standard,
onRemove,
isDragActive,
}: SortableStandardItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: standard.id })
const style = {
transform: CSS.Transform.toString(transform),
transition: isDragging ? 'none' : transition,
opacity: isDragging ? 0.9 : 1,
zIndex: isDragging ? 1000 : 'auto',
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={`flex cursor-grab select-none items-center gap-3 rounded-md border border-primary bg-primary/10 px-3 py-2 shadow-sm ${
isDragging
? 'scale-105 shadow-lg'
: !isDragActive
? 'transition-shadow duration-200 hover:shadow-md'
: ''
} active:cursor-grabbing`}
>
<div className="flex h-6 w-6 items-center justify-center text-muted-foreground">
<GripVertical className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<div className="min-w-20 shrink-0 text-sm font-medium text-foreground">
{standard.protocol_name}
</div>
<div className="truncate text-sm text-muted-foreground">
{standard.name}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<FileText className="h-3 w-3" />
<span className="hidden sm:inline">{standard.registry_number}</span>
</div>
<Badge variant="secondary" className="px-1.5 py-0.5 text-xs">
{standard.range}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 shrink-0 p-0 text-muted-foreground hover:text-destructive"
onClick={e => {
e.stopPropagation()
onRemove(standard.id)
}}
onPointerDown={e => e.stopPropagation()}
>
<X className="h-4 w-4" />
</Button>
</div>
)
}
interface StandardsSelectorProps {
isOpen: boolean
onClose: () => void
value: string[]
onChange: (value: string[]) => void
maxItems?: number // Добавляем опциональный параметр
}
export function StandardsSelector({
isOpen,
onClose,
value = [],
onChange,
maxItems = 7, // Значение по умолчанию
}: StandardsSelectorProps) {
const [selectedStandards, setSelectedStandards] = useState<string[]>(value)
const [isDragActive, setIsDragActive] = useState(false)
// Синхронизируем selectedStandards с value при изменении value
useEffect(() => {
setSelectedStandards(value)
}, [value])
const [searchTerm, setSearchTerm] = useState('')
const { error } = useToast()
const searchInputRef = useRef<HTMLInputElement>(null)
const { data: standards = [], isLoading, error: apiError } = useStandards()
// Настройка сенсоров для drag-and-drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 10, // Начинать перетаскивание после движения на 10px
tolerance: 5,
delay: 100,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
// Обработчик начала перетаскивания
const handleDragStart = () => {
setIsDragActive(true)
}
// Обработчик завершения перетаскивания
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
setIsDragActive(false)
if (over && active.id !== over.id) {
setSelectedStandards(items => {
const oldIndex = items.indexOf(active.id as string)
const newIndex = items.indexOf(over.id as string)
return arrayMove(items, oldIndex, newIndex)
})
}
}
// Автофокус на поле поиска при открытии
useEffect(() => {
if (isOpen && searchInputRef.current) {
const timer = setTimeout(() => {
searchInputRef.current?.focus()
const closeButton = document.querySelector(
'[data-state="open"] button[aria-label="Close"]'
)
if (closeButton && document.activeElement === closeButton) {
searchInputRef.current?.focus()
}
}, 150)
return () => clearTimeout(timer)
}
}, [isOpen])
// Обработка клавиш для автоматического ввода в поиск
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement
if (
activeElement?.tagName === 'INPUT' ||
activeElement?.tagName === 'TEXTAREA' ||
activeElement?.tagName === 'BUTTON' ||
activeElement?.getAttribute('contenteditable') === 'true'
) {
return
}
if (
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.key === 'Tab' ||
event.key === 'Enter' ||
event.key === 'Escape' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight'
) {
return
}
if (event.key === 'Backspace') {
event.preventDefault()
setSearchTerm(prev => prev.slice(0, -1))
searchInputRef.current?.focus()
return
}
if (event.key.length === 1) {
event.preventDefault()
setSearchTerm(prev => prev + event.key)
searchInputRef.current?.focus()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isOpen])
const filteredStandards = useMemo(() => {
if (!searchTerm) return standards
const searchLower = searchTerm.toLowerCase()
return standards.filter(
standard =>
standard.name.toLowerCase().includes(searchLower) ||
standard.protocol_name.toLowerCase().includes(searchLower) ||
standard.registry_number.toLowerCase().includes(searchLower)
)
}, [searchTerm, standards])
// Создаем массив выбранных эталонов в том порядке, как они выбраны
const selectedStandardsData = selectedStandards
.map(standardId => standards.find(standard => standard.id === standardId))
.filter((standard): standard is Standard => standard !== undefined)
const unselectedStandards = filteredStandards.filter(
standard => !selectedStandards.includes(standard.id)
)
const isMaxReached = selectedStandards.length >= maxItems
const toggleStandard = (standardId: string) => {
setSelectedStandards(prev => {
if (prev.includes(standardId)) {
return prev.filter(id => id !== standardId)
} else if (prev.length < maxItems) {
return [...prev, standardId]
} else {
error(`Можно выбрать максимум ${maxItems} эталонов`, {
title: 'Достигнут лимит',
})
return prev
}
})
}
const removeStandard = (standardId: string) => {
setSelectedStandards(prev => prev.filter(id => id !== standardId))
}
const handleSave = () => {
onChange(selectedStandards)
onClose()
}
const handleClose = () => {
setSelectedStandards(value)
setSearchTerm('')
onClose()
}
const renderStandardCard = (
standard: Standard,
isSelected: boolean,
showRemoveButton = false
) => {
const isDisabled = !isSelected && isMaxReached && !showRemoveButton
return (
<div
key={standard.id}
className={`flex items-center gap-3 rounded-md border px-3 py-2 transition-all duration-200 ${
isSelected
? 'border-primary bg-primary/10 shadow-sm'
: isDisabled
? 'border-border bg-muted/50 opacity-60'
: 'border-border hover:border-primary/50 hover:bg-primary/5'
} ${!showRemoveButton && !isDisabled ? 'cursor-pointer' : isDisabled ? 'cursor-not-allowed' : ''}`}
onClick={
!showRemoveButton && !isDisabled
? () => toggleStandard(standard.id)
: undefined
}
>
{!showRemoveButton && (
<div
className={`flex h-4 w-4 items-center justify-center rounded border transition-colors ${
isSelected
? 'border-primary bg-primary'
: isDisabled
? 'border-muted-foreground/30 bg-muted'
: 'border-border'
}`}
>
{isSelected && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<div
className={`min-w-20 shrink-0 text-sm font-medium ${
isDisabled ? 'text-muted-foreground' : 'text-foreground'
}`}
>
{standard.protocol_name}
</div>
<div
className={`truncate text-sm ${
isDisabled
? 'text-muted-foreground/70'
: 'text-muted-foreground'
}`}
>
{standard.name}
</div>
</div>
</div>
<div
className={`flex shrink-0 items-center gap-4 text-xs ${
isDisabled ? 'text-muted-foreground/50' : 'text-muted-foreground'
}`}
>
<div className="flex items-center gap-1">
<FileText className="h-3 w-3" />
<span className="hidden sm:inline">{standard.registry_number}</span>
</div>
<Badge variant="secondary" className="px-1.5 py-0.5 text-xs">
{standard.range}
</Badge>
</div>
{showRemoveButton && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 shrink-0 p-0 text-muted-foreground hover:text-destructive"
onClick={() => removeStandard(standard.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
)
}
if (isLoading) {
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="flex max-h-[95vh] max-w-6xl flex-col overflow-hidden">
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<p className="text-muted-foreground">Загрузка эталонов...</p>
</div>
</div>
</DialogContent>
</Dialog>
)
}
if (apiError) {
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="flex max-h-[95vh] max-w-6xl flex-col overflow-hidden">
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<p className="mb-2 text-destructive">Ошибка загрузки эталонов</p>
<p className="text-sm text-muted-foreground">
{apiError.message}
</p>
</div>
</div>
</DialogContent>
</Dialog>
)
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="flex max-h-[95vh] max-w-6xl flex-col overflow-hidden">
<DialogHeader className="shrink-0 pb-3">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2 text-lg">
<Settings className="h-5 w-5" />
Измерительные эталоны
<span
className={`text-sm font-normal ${
isMaxReached ? 'text-orange-600' : 'text-muted-foreground'
}`}
>
({selectedStandards.length}/{maxItems})
</span>
</DialogTitle>
</div>
</DialogHeader>
<div className="flex flex-1 flex-col space-y-4 overflow-hidden">
{/* Поиск и ссылка на управление */}
<div className="flex shrink-0 gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder="Начните печатать для поиска..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="pl-10"
autoComplete="off"
autoFocus
/>
{searchTerm && (
<Button
variant="ghost"
size="sm"
className="absolute right-2 top-1/2 h-6 w-6 -translate-y-1/2 p-0"
onClick={() => {
setSearchTerm('')
searchInputRef.current?.focus()
}}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
<Button
variant="outline"
onClick={() => {
// Открыть страницу управления эталонами
window.open('/standards-management', '_blank')
}}
className="shrink-0"
>
<ExternalLink className="mr-2 h-4 w-4" />
Управление
</Button>
</div>
{/* Контент с прокруткой */}
<div className="flex-1 space-y-4 overflow-y-auto">
{/* Выбранные эталоны */}
{selectedStandardsData.length > 0 && (
<div>
<h4 className="mb-3 text-sm font-medium text-foreground">
Выбранные эталоны ({selectedStandardsData.length})
<span className="ml-2 text-xs text-muted-foreground">
Перетащите для изменения порядка
</span>
</h4>
<div className="max-h-64 space-y-1 overflow-y-auto">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={selectedStandards}
strategy={verticalListSortingStrategy}
>
{selectedStandardsData.map(standard => (
<SortableStandardItem
key={standard.id}
standard={standard}
onRemove={removeStandard}
isDragActive={isDragActive}
/>
))}
</SortableContext>
</DndContext>
</div>
</div>
)}
{selectedStandardsData.length > 0 &&
unselectedStandards.length > 0 && <Separator />}
{/* Доступные эталоны */}
{unselectedStandards.length > 0 && (
<div>
<h4 className="mb-3 text-sm font-medium text-foreground">
Доступные эталоны ({unselectedStandards.length})
{isMaxReached && (
<span className="ml-2 text-xs text-orange-600">
Достигнут лимит выбора
</span>
)}
</h4>
<div className="space-y-1">
{unselectedStandards.map(standard =>
renderStandardCard(standard, false)
)}
</div>
</div>
)}
{filteredStandards.length === 0 && searchTerm && (
<div className="py-8 text-center text-muted-foreground">
Эталоны не найдены по запросу "{searchTerm}"
</div>
)}
{standards.length === 0 && !searchTerm && (
<div className="py-8 text-center text-muted-foreground">
<Settings className="mx-auto mb-2 h-8 w-8 opacity-50" />
Эталоны не созданы
</div>
)}
</div>
</div>
{/* Зафиксированные кнопки */}
<div className="mt-4 shrink-0 border-t pt-3">
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleClose} size="sm">
Отмена
</Button>
<Button onClick={handleSave} size="sm">
Сохранить
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,79 @@
import {
CreateStandardInput,
CreateStandardOutput,
GetStandardOutput,
GetStandardsOutput,
PatchStandardInput,
PatchStandardOutput,
} from './types'
const BASE_URL = '/api'
// Получить все эталоны
export const getStandards = async (): Promise<GetStandardsOutput> => {
console.log('getStandards', BASE_URL)
const response = await fetch(`${BASE_URL}/standards/`)
if (!response.ok) {
throw new Error('Failed to fetch standards')
}
return response.json()
}
// Получить один эталон
export const getStandard = async (
standardId: string
): Promise<GetStandardOutput> => {
const response = await fetch(`${BASE_URL}/standards/${standardId}`)
if (!response.ok) {
throw new Error('Failed to fetch standard')
}
return response.json()
}
// Создать эталон
export const createStandard = async (
input: CreateStandardInput
): Promise<CreateStandardOutput> => {
const response = await fetch(`${BASE_URL}/standards/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
})
if (!response.ok) {
throw new Error('Failed to create standard')
}
return response.json()
}
// Обновить эталон
export const patchStandard = async (
standardId: string,
input: PatchStandardInput
): Promise<PatchStandardOutput> => {
const response = await fetch(`${BASE_URL}/standards/${standardId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
})
if (!response.ok) {
throw new Error('Failed to update standard')
}
return response.json()
}
// Удалить эталон
export const deleteStandard = async (
standardId: string
): Promise<{ [key: string]: string }> => {
const response = await fetch(`${BASE_URL}/standards/${standardId}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete standard')
}
return response.json()
}

View File

@@ -0,0 +1,208 @@
import { Badge } from '@/component/ui/badge'
import { Button } from '@/component/ui/button'
import { Label } from '@/component/ui/label'
import { ElementDefinition } from '@/entity/element/model/interface'
import { CellsBadge } from '@/entity/element/ui/cellsBadge'
import { CellTarget } from '@/type/template'
import { FileText, Settings, Weight } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useStandards } from './hooks'
import { StandardsEditor } from './StandardsEditor'
import { StandardsPreview } from './StandardsPreview'
import { StandardsSelector } from './StandardsSelector'
export interface StandardsConfig {
maxItems: number
targetCells: CellTarget[]
selectedStandards?: string[]
name?: string
}
// Новый интерфейс для значения элемента эталонов
export interface StandardsValue {
standardIds: string[]
standardNames: string[] // пронумерованные protocol_name для записи в ячейки (1. Name, 2. Name)
}
export const standardsDefinition: ElementDefinition<
StandardsConfig,
StandardsValue
> = {
type: 'standards',
label: 'Выбор эталонов',
icon: <Weight className="h-4 w-4" />,
version: 1,
defaultConfig: {
maxItems: 7,
targetCells: [],
selectedStandards: [],
name: '',
},
Editor: StandardsEditor,
Preview: StandardsPreview,
// Получение начальных значений из конфигурации
getInitialValue: config => {
return {
standardIds: config.selectedStandards || [],
standardNames: [], // Будет заполнено при рендере компонента
}
},
Render: ({ config, value, onChange }) => {
const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false)
const [selectedStandardIds, setSelectedStandardIds] = useState<string[]>(
// Инициализируем из value.standardIds, config.selectedStandards или пустого массива
value?.standardIds || config.selectedStandards || []
)
// Получаем реальные данные эталонов
const { data: standards = [] } = useStandards()
// Синхронизируем selectedStandardIds с value и config
useEffect(() => {
const standardIds = value?.standardIds || config.selectedStandards || []
setSelectedStandardIds(standardIds)
}, [value?.standardIds, config.selectedStandards])
// Получаем данные выбранных эталонов из реального API
const selectedStandardsData = selectedStandardIds
.map(id => standards.find(s => s.id === id))
.filter(Boolean)
// Обновляем standardNames при получении данных из API, если они еще не заполнены
useEffect(() => {
if (
standards.length > 0 &&
selectedStandardIds.length > 0 &&
(!value?.standardNames || value.standardNames.length === 0) &&
onChange
) {
const standardNames = selectedStandardIds
.map((id, index) => {
const standard = standards.find(s => s.id === id)
return standard?.protocol_name
? `${index + 1}. ${standard.protocol_name}`
: ''
})
.filter(Boolean)
if (standardNames.length > 0) {
onChange({
standardIds: selectedStandardIds,
standardNames,
})
}
}
}, [standards, selectedStandardIds, value?.standardNames, onChange])
return (
<div className="relative space-y-2">
{/* Бейдж ячеек */}
<CellsBadge targetCells={config.targetCells} />
{/* Блок с измерительными эталонами */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label className="text-sm font-medium">Эталоны</Label>
<Button
variant="outline"
size="sm"
onClick={() => setIsStandardsDialogOpen(true)}
className="h-7 px-2"
>
<Settings className="mr-1 h-3 w-3" />
Настроить
</Button>
</div>
<div className="space-y-2">
{selectedStandardsData.length > 0 ? (
<div className="space-y-1">
{selectedStandardsData.map(
(standard, index) =>
standard && (
<div
key={standard.id}
className="flex items-center gap-2 rounded-md bg-muted/30 p-2 text-sm"
>
<div className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/10">
<span className="text-xs font-medium text-primary">
{index + 1}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-foreground">
{standard.name}
</div>
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<FileText className="h-2.5 w-2.5" />
<span className="hidden text-xs sm:inline">
{standard.registry_number}
</span>
</div>
</div>
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-xs"
>
{standard.range}
</Badge>
</div>
)
)}
</div>
) : (
<div className="rounded-md border border-dashed bg-muted/30 p-3 text-center text-sm text-muted-foreground">
<Settings className="mx-auto mb-1 h-4 w-4 opacity-50" />
Эталоны не выбраны
</div>
)}
</div>
</div>
<StandardsSelector
isOpen={isStandardsDialogOpen}
onClose={() => setIsStandardsDialogOpen(false)}
value={selectedStandardIds}
onChange={standardIds => {
setSelectedStandardIds(standardIds)
// Получаем protocol_name эталонов для выбранных ID с нумерацией
const standardNames = standardIds
.map((id, index) => {
const standard = standards.find(s => s.id === id)
return standard?.protocol_name
? `${index + 1}. ${standard.protocol_name}`
: ''
})
.filter(Boolean)
// Сохраняем полную информацию: ID и protocol_name
onChange?.({
standardIds,
standardNames,
})
}}
maxItems={config.maxItems}
/>
</div>
)
},
mapToCells: cfg => {
// Возвращаем ячейки из конфигурации
return cfg.targetCells || []
},
mapToCellValues: (cfg, value) => {
// Используем targetCells из конфигурации
const cells = cfg.targetCells || []
// value теперь содержит как ID, так и пронумерованные protocol_name эталонов
const standardNames = value?.standardNames || []
return cells.map((target, idx) => ({
target,
value: standardNames[idx] || '',
}))
},
}

View File

@@ -0,0 +1,83 @@
import { useToast } from '@/lib/hooks/useToast'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import * as api from './api'
import { CreateStandardInput, PatchStandardInput } from './types'
// Ключи для React Query
export const standardsKeys = {
all: ['standards'] as const,
detail: (id: string) => ['standards', id] as const,
}
// Получить все эталоны
export const useStandards = () => {
return useQuery({
queryKey: standardsKeys.all,
queryFn: api.getStandards,
select: data => data.standards,
})
}
// Получить один эталон
export const useStandard = (standardId: string) => {
return useQuery({
queryKey: standardsKeys.detail(standardId),
queryFn: () => api.getStandard(standardId),
select: data => data.standard,
enabled: !!standardId,
})
}
// Создать эталон
export const useCreateStandard = () => {
const queryClient = useQueryClient()
const { success, error } = useToast()
return useMutation({
mutationFn: (input: CreateStandardInput) => api.createStandard(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: standardsKeys.all })
success('Эталон успешно создан')
},
onError: (err: Error) => {
error(`Ошибка создания эталона: ${err.message}`)
},
})
}
// Обновить эталон
export const useUpdateStandard = () => {
const queryClient = useQueryClient()
const { success, error } = useToast()
return useMutation({
mutationFn: ({ id, input }: { id: string; input: PatchStandardInput }) =>
api.patchStandard(id, input),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: standardsKeys.all })
queryClient.invalidateQueries({ queryKey: standardsKeys.detail(id) })
success('Эталон успешно обновлен')
},
onError: (err: Error) => {
error(`Ошибка обновления эталона: ${err.message}`)
},
})
}
// Удалить эталон
export const useDeleteStandard = () => {
const queryClient = useQueryClient()
const { success, error } = useToast()
return useMutation({
mutationFn: (standardId: string) => api.deleteStandard(standardId),
onSuccess: (_, standardId) => {
queryClient.invalidateQueries({ queryKey: standardsKeys.all })
queryClient.removeQueries({ queryKey: standardsKeys.detail(standardId) })
success('Эталон успешно удален')
},
onError: (err: Error) => {
error(`Ошибка удаления эталона: ${err.message}`)
},
})
}

View File

@@ -0,0 +1,47 @@
// Типы на основе API схемы
export interface Standard {
id: string
name: string
protocol_name: string // было shortName
registry_number: string
range: string
accuracy: string
valid_until: string // date format
created_at: string // datetime
updated_at: string // datetime
deleted_at: string | null // datetime or null
}
export interface CreateStandardInput {
name: string
protocol_name: string
registry_number: string
range: string
accuracy: string
valid_until: string // date format
}
export interface CreateStandardOutput {
id: string
}
export interface PatchStandardInput {
name?: string | null
protocol_name?: string | null
registry_number?: string | null
range?: string | null
accuracy?: string | null
valid_until?: string | null
}
export interface PatchStandardOutput {
id: string
}
export interface GetStandardOutput {
standard: Standard | null
}
export interface GetStandardsOutput {
standards: Standard[]
}

View File

@@ -0,0 +1,403 @@
import { Checkbox } from '@/component/ui/checkbox'
import { Input } from '@/component/ui/input'
import { ElementDefinition } from '@/entity/element/model/interface'
import { CellsBadge } from '@/entity/element/ui/cellsBadge'
import { Plus, Save, SaveOff, Type } from 'lucide-react'
import { useEffect, useState } from 'react'
interface TextConfig {
placeholder?: string
required?: boolean
showIncrementButton?: boolean // Новый параметр для отображения кнопки +1
targetCells: any[]
name?: string
id?: string // Добавляем id для совместимости с TemplateElement
}
// Утилиты для работы с localStorage
const getLocalStorageKey = (type: string, id: string) => `${type}_${id}`
const saveToLocalStorage = (key: string, value: string) => {
try {
localStorage.setItem(key, value)
} catch (error) {
console.warn('Failed to save to localStorage:', error)
}
}
const getFromLocalStorage = (key: string): string => {
try {
return localStorage.getItem(key) || ''
} catch (error) {
console.warn('Failed to get from localStorage:', error)
return ''
}
}
const removeFromLocalStorage = (key: string) => {
try {
localStorage.removeItem(key)
} catch (error) {
console.warn('Failed to remove from localStorage:', error)
}
}
export const textDefinition: ElementDefinition<TextConfig, string> = {
type: 'text',
label: 'Текстовое поле',
icon: <Type className="h-4 w-4" />,
version: 1,
defaultConfig: {
placeholder: '',
required: false,
showIncrementButton: false,
targetCells: [],
name: '',
},
getInitialValue: config => {
// Используем id из config (который приходит из TemplateElement)
if (config.id) {
const key = getLocalStorageKey('text', config.id)
return getFromLocalStorage(key)
}
return ''
},
mapToCells: config => config.targetCells || [],
mapToCellValues: (config, value) => {
const cells = config.targetCells || []
return cells.map(target => ({ target, value: value || '' }))
},
Editor: ({ config, onChange }) => (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="showIncrementButton"
checked={config.showIncrementButton || false}
onCheckedChange={checked =>
onChange({ ...config, showIncrementButton: !!checked })
}
/>
<label
htmlFor="showIncrementButton"
className="text-sm font-medium text-gray-900"
>
Показывать кнопку +1
</label>
</div>
</div>
),
Preview: ({ config }) => (
<div className="space-y-1">
{config.name && (
<label className="text-sm font-medium text-gray-900">
{config.name}
{config.required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<div className="relative flex items-center">
<Input
placeholder={config.placeholder}
className={`w-full ${config.showIncrementButton ? 'pr-10' : ''}`}
/>
{config.showIncrementButton && (
<button
className="absolute right-1 flex h-8 w-8 items-center justify-center rounded-md bg-blue-500 text-white"
disabled
>
<Plus className="h-4 w-4" />
</button>
)}
</div>
</div>
),
Render: ({ config, value, onChange }) => {
const [localStorageEnabled, setLocalStorageEnabled] = useState(false)
const [currentValue, setCurrentValue] = useState(value || '')
const [isAnimating, setIsAnimating] = useState(false)
const [animatingDigits, setAnimatingDigits] = useState<(number | null)[]>(
[]
)
const [flyingDigit, setFlyingDigit] = useState<{
show: boolean
x: number
y: number
}>({ show: false, x: 0, y: 0 })
// Инициализация состояния localStorage из сохраненных данных
useEffect(() => {
if (config.id) {
const key = getLocalStorageKey('text', config.id)
const savedValue = getFromLocalStorage(key)
// Если есть сохраненное значение, включаем localStorage и загружаем его
if (savedValue) {
setLocalStorageEnabled(true)
if (savedValue !== currentValue) {
setCurrentValue(savedValue)
onChange?.(savedValue)
}
}
}
}, [config.id])
// Синхронизация внешнего значения
useEffect(() => {
if (value !== currentValue) {
setCurrentValue(value || '')
}
}, [value])
const handleValueChange = (newValue: string) => {
setCurrentValue(newValue)
onChange?.(newValue)
// Сохранение в localStorage если включено
if (localStorageEnabled && config.id) {
const key = getLocalStorageKey('text', config.id)
saveToLocalStorage(key, newValue)
}
}
const handleLocalStorageToggle = () => {
const newEnabled = !localStorageEnabled
setLocalStorageEnabled(newEnabled)
if (config.id) {
const key = getLocalStorageKey('text', config.id)
if (newEnabled) {
// Включаем localStorage - сохраняем текущее значение
saveToLocalStorage(key, currentValue)
} else {
// Выключаем localStorage - удаляем сохраненное значение
removeFromLocalStorage(key)
}
}
}
const handleIncrementClick = () => {
if (isAnimating) return
const currentNum = parseFloat(currentValue) || 0
const newNum = currentNum + 1
setIsAnimating(true)
// Получаем позицию кнопки для анимации вылета
const buttonElement = document.querySelector(
'[data-increment-button]'
) as HTMLElement
if (buttonElement) {
const rect = buttonElement.getBoundingClientRect()
setFlyingDigit({
show: true,
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
})
}
// Убираем летящую цифру через 400мс
setTimeout(() => {
setFlyingDigit(prev => ({ ...prev, show: false }))
}, 400)
// Преобразуем числа в массивы цифр
const currentStr = currentNum.toString()
const newStr = newNum.toString()
// Определяем максимальную длину для выравнивания
const maxLength = Math.max(currentStr.length, newStr.length)
const currentDigits = currentStr
.padStart(maxLength, '0')
.split('')
.map(d => parseInt(d))
const newDigits = newStr
.padStart(maxLength, '0')
.split('')
.map(d => parseInt(d))
// Инициализируем массив анимируемых цифр
setAnimatingDigits(currentDigits)
// Анимация всех цифр
const startTime = Date.now()
const duration = 600
const animateDigits = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// Используем easing функцию для плавности
const easeOutQuad = (t: number) => t * (2 - t)
const smoothProgress = easeOutQuad(progress)
// Анимируем каждую цифру отдельно
const currentAnimatedDigits = currentDigits.map((startDigit, index) => {
const endDigit = newDigits[index]
// Если цифра не изменилась, возвращаем исходную
if (startDigit === endDigit) {
return startDigit
}
// Интерполируем между старой и новой цифрой
const currentDigit =
startDigit + (endDigit - startDigit) * smoothProgress
return Math.round(currentDigit)
})
setAnimatingDigits(currentAnimatedDigits)
if (progress < 1) {
requestAnimationFrame(animateDigits)
} else {
setAnimatingDigits([])
setIsAnimating(false)
handleValueChange(newNum.toString())
}
}
requestAnimationFrame(animateDigits)
}
// Проверяем, является ли текущее значение числом
const isNumericValue =
!isNaN(parseFloat(currentValue)) && isFinite(parseFloat(currentValue))
// Функция для отображения значения с анимированными цифрами
const getDisplayValue = () => {
if (!isAnimating || !isNumericValue || animatingDigits.length === 0) {
return currentValue
}
// Собираем число из анимированных цифр и убираем ведущие нули
const animatedStr = animatingDigits.join('')
return animatedStr.replace(/^0+/, '') || '0'
}
return (
<div className="relative space-y-1">
{/* Летящая единичка */}
{flyingDigit.show && (
<>
<style>
{`
@keyframes flyToInput {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
50% {
transform: translate(-150%, -100%) scale(1.2);
opacity: 0.8;
}
100% {
transform: translate(-300%, -150%) scale(0.8);
opacity: 0;
}
}
`}
</style>
<div
className="pointer-events-none fixed z-50 text-2xl font-bold text-blue-500"
style={{
left: flyingDigit.x,
top: flyingDigit.y,
transform: 'translate(-50%, -50%)',
animation: 'flyToInput 400ms ease-out forwards',
}}
>
+1
</div>
</>
)}
{/* Бейджи в правом верхнем углу */}
<div className="absolute right-0 top-0 z-10 flex items-center gap-1">
{/* Галочка сохранения */}
{config.id && (
<div className="group/save">
<button
onClick={handleLocalStorageToggle}
className="cursor-pointer"
>
{localStorageEnabled ? (
<Save className="h-3 w-3 text-green-600 opacity-80 transition-opacity hover:opacity-100" />
) : (
<SaveOff className="h-3 w-3 cursor-help text-gray-500 opacity-60 transition-opacity hover:opacity-100" />
)}
</button>
<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/save:opacity-100">
<div className="font-medium">
{localStorageEnabled
? 'Автосохранение включено'
: 'Автосохранение выключено'}
</div>
<div className="text-gray-300">
{localStorageEnabled
? 'Значение сохраняется в браузере'
: 'Нажмите, чтобы включить автосохранение'}
</div>
</div>
</div>
)}
{/* Бейдж ячеек */}
<CellsBadge targetCells={config.targetCells} className="" />
</div>
{/* Лейбл поля */}
{config.name && (
<label className="text-sm font-medium text-gray-900">
{config.name}
{config.required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
{/* Поле ввода с кнопкой */}
<div className="relative flex items-center">
<Input
placeholder={config.placeholder}
value={getDisplayValue()}
onChange={e => {
if (!isAnimating) {
// Автоматически заменяем запятые на точки
const normalizedValue = e.target.value.replace(/,/g, '.')
handleValueChange(normalizedValue)
}
}}
required={config.required}
className={`${config.showIncrementButton ? 'pr-10' : ''} ${
isAnimating && isNumericValue
? 'font-mono text-lg font-bold tracking-wider text-blue-600 transition-all duration-300'
: ''
}`}
readOnly={isAnimating}
/>
{/* Кнопка +1 */}
{config.showIncrementButton && (
<button
data-increment-button
onClick={handleIncrementClick}
disabled={isAnimating}
className={`absolute right-1 z-10 flex h-8 w-8 items-center justify-center rounded-md transition-all duration-300 hover:bg-blue-600 disabled:opacity-75 ${
isAnimating
? 'scale-125 bg-green-500 shadow-xl ring-4 ring-green-300 ring-opacity-50'
: 'scale-100 bg-blue-500 hover:shadow-lg'
}`}
title="Увеличить на 1"
>
<Plus
className={`h-4 w-4 text-white transition-all duration-500 ${
isAnimating ? 'rotate-180 scale-150' : 'rotate-0 scale-100'
}`}
/>
</button>
)}
</div>
</div>
)
},
}

View File

@@ -0,0 +1,246 @@
import { Badge } from '@/component/ui/badge'
import { Label } from '@/component/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/component/ui/select'
import { CellTarget } from '@/type/template'
import { Building2, Calendar } from 'lucide-react'
import { useLaboratories, useLaboratoryConditions } from './hooks'
import { ConditionMapping, VerificationConditionsConfig } from './types'
interface VerificationConditionsEditorProps {
config: VerificationConditionsConfig & { targetCells?: CellTarget[] }
onChange: (config: VerificationConditionsConfig) => void
}
export function VerificationConditionsEditor({
config,
onChange,
}: VerificationConditionsEditorProps) {
const { data: laboratories = [], isLoading } = useLaboratories()
const { data: labData } = useLaboratoryConditions(config.selectedLaboratoryId)
const updateConfig = (updates: Partial<VerificationConditionsConfig>) => {
onChange({ ...config, ...updates })
}
// Получить доступные целевые ячейки из конфигурации
const getAvailableTargetCells = () => {
return config.targetCells || []
}
// Обновить маппинг для конкретного условия (включая дату поверки)
const updateConditionMapping = (
conditionId: string,
conditionName: string,
targetCellKey?: string // формат: "sheet:cell", например "L:A1"
) => {
const currentMappings = config.conditionMappings || []
let updatedMappings = [...currentMappings]
// Удаляем существующий маппинг для этого условия
updatedMappings = updatedMappings.filter(
mapping => mapping.conditionKey !== conditionId
)
// Если выбрана ячейка, добавляем новый маппинг
if (targetCellKey) {
const [sheet, cell] = targetCellKey.split(':')
const targetCells = getAvailableTargetCells()
const targetIndex = targetCells.findIndex(
tc => tc.sheet === sheet && tc.cell === cell
)
if (targetIndex !== -1) {
const newMapping: ConditionMapping = {
conditionKey: conditionId,
conditionName: conditionName,
cellIndex: targetIndex, // используем индекс в массиве targetCells
}
updatedMappings.push(newMapping)
}
}
updateConfig({ conditionMappings: updatedMappings })
}
// Получить выбранную ячейку для условия
const getSelectedTargetCell = (conditionId: string): string | undefined => {
const mapping = (config.conditionMappings || []).find(
mapping => mapping.conditionKey === conditionId
)
if (!mapping) return undefined
const targetCells = getAvailableTargetCells()
const targetCell = targetCells[mapping.cellIndex]
return targetCell ? `${targetCell.sheet}:${targetCell.cell}` : undefined
}
// Получить занятые ячейки (исключая текущее условие)
const getOccupiedTargetCells = (excludeConditionId?: string): string[] => {
const targetCells = getAvailableTargetCells()
return (config.conditionMappings || [])
.filter(mapping => mapping.conditionKey !== excludeConditionId)
.map(mapping => {
const targetCell = targetCells[mapping.cellIndex]
return targetCell ? `${targetCell.sheet}:${targetCell.cell}` : ''
})
.filter(Boolean)
}
const targetCells = getAvailableTargetCells()
// Создаем список всех условий включая дату поверки
const allConditions = [
// Дата поверки как первый элемент
{
id: 'verification_date',
name: 'Дата поверки',
unit: '',
isDate: true,
},
// Условия из лаборатории
...(labData?.conditionTypes || []).map(ct => ({
id: ct.id,
name: ct.name,
unit: ct.unit,
isDate: false,
})),
]
return (
<div className="space-y-4">
{/* Компактный выбор лаборатории */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-blue-600" />
<Label className="text-sm font-medium">Лаборатория</Label>
</div>
<div className="flex-1">
<Select
value={config.selectedLaboratoryId || ''}
onValueChange={value =>
updateConfig({
selectedLaboratoryId: value || undefined,
})
}
disabled={isLoading}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue
placeholder={isLoading ? 'Загрузка...' : 'Выберите лабораторию'}
/>
</SelectTrigger>
<SelectContent>
{laboratories.map(lab => (
<SelectItem key={lab.id} value={lab.id}>
{lab.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Маппинг условий (включая дату поверки) */}
{targetCells.length > 0 && (
<div className="space-y-3">
<Label className="text-sm font-medium text-gray-700">
Маппинг условий и даты поверки
</Label>
<div className="space-y-2">
{allConditions.map(condition => {
const selectedCell = getSelectedTargetCell(condition.id)
const occupiedCells = getOccupiedTargetCells(condition.id)
return (
<div
key={condition.id}
className="flex items-center justify-between gap-3 rounded-md border border-gray-200 bg-gray-50/50 px-3 py-2"
>
{/* Информация об условии */}
<div className="flex items-center gap-2">
{condition.isDate && (
<Calendar className="h-4 w-4 text-green-600" />
)}
<span className="text-sm font-medium text-gray-900">
{condition.name}
</span>
{condition.unit && (
<Badge
variant="secondary"
className="px-2 py-0.5 text-xs"
>
{condition.unit}
</Badge>
)}
</div>
{/* Компактный селектор ячейки */}
<Select
value={selectedCell || 'UNSELECTED'}
onValueChange={value =>
updateConditionMapping(
condition.id,
condition.name,
value === 'UNSELECTED' ? undefined : value
)
}
>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value="UNSELECTED"></SelectItem>
{targetCells
.map(
(target, index) => `${target.sheet}:${target.cell}`
)
.filter(cellKey => !occupiedCells.includes(cellKey))
.map(cellKey => (
<SelectItem key={cellKey} value={cellKey}>
{cellKey}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
})}
{allConditions.length === 1 && (
<div className="rounded-md border border-dashed border-gray-300 py-4 text-center">
<p className="text-xs text-gray-500">
У выбранной лаборатории нет настроенных типов условий
</p>
</div>
)}
</div>
</div>
)}
{/* Предупреждение если нет целевых ячеек */}
{targetCells.length === 0 && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2">
<p className="text-xs text-amber-700">
Добавьте целевые ячейки в настройки элемента для маппинга условий
поверки
</p>
</div>
)}
{/* Предупреждение если выбрана лаборатория но нет ячеек */}
{labData && targetCells.length === 0 && (
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
<p className="text-xs text-blue-700">
Настройте целевые ячейки для сопоставления условий поверки
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { Button } from '@/component/ui/button'
import { Card, CardContent } from '@/component/ui/card'
import {
AlertCircle,
Building2,
Calendar as CalendarIcon,
Droplets,
Gauge,
Settings,
Thermometer,
} from 'lucide-react'
import { VerificationConditionsConfig } from './types'
interface VerificationConditionsPreviewProps {
config: VerificationConditionsConfig
onChange?: (config: VerificationConditionsConfig) => void
}
const ConditionSkeleton = ({
index,
icon: Icon,
name,
unit,
}: {
index: number
icon: any
name: string
unit: string
}) => (
<div className="flex items-center gap-2 rounded-md bg-muted/30 p-1.5 text-sm">
<div className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Icon className="h-2.5 w-2.5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-foreground">{name}</div>
</div>
<div className="h-5 shrink-0 rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{unit}
</div>
</div>
)
// Моковые условия поверки
const mockConditions = [
{ name: 'Температура', unit: '°C', icon: Thermometer },
{ name: 'Влажность', unit: '%', icon: Droplets },
{ name: 'Давление', unit: 'кПа', icon: Gauge },
{ name: 'Освещенность', unit: 'лк', icon: AlertCircle },
{ name: 'Вибрация', unit: 'м/с²', icon: AlertCircle },
{ name: 'Магнитное поле', unit: 'А/м', icon: AlertCircle },
{ name: 'Электромагнитное поле', unit: 'В/м', icon: AlertCircle },
]
export function VerificationConditionsPreview({
config,
onChange,
}: VerificationConditionsPreviewProps) {
const conditionsToShow = mockConditions.slice(0, 7)
const selectedDate =
config.selectedDate || new Date().toISOString().split('T')[0]
if (!config.selectedLaboratoryId) {
return (
<Card className="border-dashed">
<CardContent className="p-3">
<div className="text-center text-muted-foreground">
<Building2 className="mx-auto mb-1 h-4 w-4 opacity-50" />
<p className="text-xs">Лаборатория не выбрана</p>
</div>
</CardContent>
</Card>
)
}
return (
<div className="space-y-2">
<div className="space-y-2">
{/* Информация о лаборатории и дате */}
<div className="flex items-center gap-2 rounded-md bg-muted/20 p-2 text-sm">
<Building2 className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-muted-foreground">Лаборатория</span>
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<CalendarIcon className="h-3 w-3" />
<span>{new Date(selectedDate).toLocaleDateString('ru-RU')}</span>
</div>
</div>
{/* Общий статус с кнопкой настроить */}
<div className="flex items-center justify-between px-2 text-xs">
<div className="flex items-center gap-1.5 text-orange-600">
<AlertCircle className="h-3.5 w-3.5" />
<span className="font-medium">Данные не внесены</span>
</div>
<Button
variant="outline"
size="sm"
disabled
className="h-6 px-2 text-xs"
>
<Settings className="mr-1 h-3 w-3" />
Настроить
</Button>
</div>
{/* Моковые условия */}
<div className="space-y-1">
{conditionsToShow.map((condition, index) => (
<ConditionSkeleton
key={index}
index={index}
icon={condition.icon}
name={condition.name}
unit={condition.unit}
/>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,402 @@
import { Button } from '@/component/ui/button'
import { Card, CardContent } from '@/component/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/component/ui/dialog'
import { Input } from '@/component/ui/input'
import { Label } from '@/component/ui/label'
import { CellsBadge } from '@/entity/element/ui/cellsBadge'
import {
AlertCircle,
Building2,
Calendar as CalendarIcon,
CheckCircle,
Droplets,
Gauge,
Settings,
Thermometer,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import {
useDailyConditions,
useLaboratories,
useLaboratoryConditions,
} from './hooks'
import {
VerificationConditionsConfig,
VerificationConditionsValue,
} from './types'
// Утилиты для работы с localStorage
const getLocalStorageKey = (type: string, id: string) => `${type}_${id}`
const saveToLocalStorage = (key: string, value: string) => {
try {
localStorage.setItem(key, value)
} catch (error) {
console.warn('Failed to save to localStorage:', error)
}
}
const getFromLocalStorage = (key: string): string => {
try {
return localStorage.getItem(key) || ''
} catch (error) {
console.warn('Failed to get from localStorage:', error)
return ''
}
}
// Иконки для разных типов условий
const getConditionIcon = (name: string) => {
const lowerName = name.toLowerCase()
if (lowerName.includes('температур')) return Thermometer
if (lowerName.includes('влажн')) return Droplets
if (lowerName.includes('давлен')) return Gauge
return AlertCircle
}
const ConditionItem = ({
name,
value,
unit,
}: {
name: string
value?: string
unit?: string
}) => {
const Icon = getConditionIcon(name)
return (
<div className="flex items-center gap-2 rounded-md bg-muted/30 p-1.5 text-sm">
<div className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Icon className="h-2.5 w-2.5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-foreground">{name}</div>
</div>
<div className="h-5 shrink-0 rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{value || '—'} {unit || ''}
</div>
</div>
)
}
interface VerificationConditionsRenderProps {
config: VerificationConditionsConfig & { id?: string }
value?: VerificationConditionsValue
onChange?: (value: VerificationConditionsValue) => void
onSave?: (value: VerificationConditionsValue) => Promise<void>
}
export function VerificationConditionsRender({
config,
value,
onChange,
onSave,
}: VerificationConditionsRenderProps) {
// Инициализация даты с учетом localStorage
const getInitialDate = () => {
if (value?.date) return value.date
if (config.selectedDate) return config.selectedDate
// Попытка загрузить из localStorage
if (config.id) {
const key = getLocalStorageKey('verification_conditions_date', config.id)
const savedDate = getFromLocalStorage(key)
if (savedDate) return savedDate
}
return new Date().toISOString().split('T')[0]
}
const [selectedDate, setSelectedDate] = useState(getInitialDate)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [editingConditions, setEditingConditions] = useState<
Record<string, string>
>({})
const { data: laboratories = [] } = useLaboratories()
const { data: labData } = useLaboratoryConditions(config.selectedLaboratoryId)
const {
data: dailyCondition,
saveConditions,
isSaving,
} = useDailyConditions(config.selectedLaboratoryId, selectedDate)
const selectedLab = laboratories.find(
lab => lab.id === config.selectedLaboratoryId
)
const hasConditions =
dailyCondition && Object.keys(dailyCondition.conditions).length > 0
// Инициализация данных при загрузке из БД, только если данные еще не заполнены
useEffect(() => {
if (
dailyCondition &&
hasConditions &&
onChange &&
(!value?.conditions || Object.keys(value.conditions).length === 0)
) {
onChange({
date: selectedDate,
conditions: dailyCondition.conditions,
})
}
}, [dailyCondition, hasConditions, onChange, selectedDate, value?.conditions])
// Синхронизация selectedDate с внешним value.date
useEffect(() => {
if (value?.date && value.date !== selectedDate) {
setSelectedDate(value.date)
}
}, [value?.date, selectedDate])
// Инициализация значения если оно не установлено
useEffect(() => {
if (!value && onChange) {
onChange({
date: selectedDate,
conditions: {},
})
}
}, [value, onChange, selectedDate])
const handleDateChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newDate = event.target.value
setSelectedDate(newDate)
// Сохранение в localStorage
if (config.id) {
const key = getLocalStorageKey('verification_conditions_date', config.id)
saveToLocalStorage(key, newDate)
}
// Обновляем значение даты в value
if (onChange) {
onChange({
date: newDate,
conditions: value?.conditions || {},
})
}
}
const handleOpenEditDialog = (isCreating = false) => {
if (isCreating) {
const emptyConditions: Record<string, string> = {}
labData?.conditionTypes.forEach(conditionType => {
emptyConditions[conditionType.id] = ''
})
setEditingConditions(emptyConditions)
} else {
setEditingConditions(dailyCondition?.conditions || {})
}
setEditDialogOpen(true)
}
const handleSaveConditions = async () => {
if (!config.selectedLaboratoryId || !selectedDate || !labData) return
try {
await saveConditions(editingConditions)
if (onChange) {
onChange({
date: selectedDate,
conditions: editingConditions,
})
}
setEditDialogOpen(false)
} catch (error) {
console.error('Ошибка сохранения:', error)
}
}
const handleConditionChange = (conditionId: string, value: string) => {
setEditingConditions(prev => ({
...prev,
[conditionId]: value,
}))
}
if (!config.selectedLaboratoryId) {
return (
<div className="relative">
<CellsBadge targetCells={config.targetCells || []} />
<Card className="border-dashed">
<CardContent className="p-3">
<div className="text-center text-muted-foreground">
<Building2 className="mx-auto mb-1 h-4 w-4 opacity-50" />
<p className="text-xs">Лаборатория не выбрана</p>
</div>
</CardContent>
</Card>
</div>
)
}
if (!labData) {
return (
<div className="relative">
<CellsBadge targetCells={config.targetCells || []} />
<Card>
<CardContent className="p-3">
<div className="text-center text-muted-foreground">
<div className="mx-auto mb-1 h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-xs">Загрузка...</p>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="relative">
{/* Бейдж ячеек */}
<CellsBadge targetCells={config.targetCells || []} />
<div className="space-y-2">
<div className="space-y-2">
{/* Информация о лаборатории и дате */}
<div className="flex items-center gap-2 rounded-md bg-muted/20 p-2 text-sm">
<Building2 className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-muted-foreground">
{selectedLab?.name || 'Лаборатория'}
</span>
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<CalendarIcon className="h-3 w-3" />
<Input
type="date"
value={selectedDate}
onChange={handleDateChange}
lang="ru"
className="h-5 w-auto border-none bg-transparent p-0 text-xs font-normal focus:bg-background"
/>
</div>
</div>
{/* Общий статус с кнопкой настроить */}
<div className="flex items-center justify-between px-2 text-xs">
<div className="flex items-center gap-1.5">
{hasConditions ? (
<>
<CheckCircle className="h-3.5 w-3.5 text-green-600" />
<span className="font-medium text-green-600">
Данные внесены
</span>
</>
) : (
<>
<AlertCircle className="h-3.5 w-3.5 text-orange-600" />
<span className="font-medium text-orange-600">
Данные не внесены
</span>
</>
)}
</div>
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleOpenEditDialog(!hasConditions)}
>
<Settings className="mr-1 h-3 w-3" />
Настроить
</Button>
</DialogTrigger>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-base">
Условия поверки
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1 text-xs text-muted-foreground">
<div>{selectedLab?.name}</div>
<div>
{new Date(selectedDate).toLocaleDateString('ru-RU')}
</div>
</div>
<div className="space-y-2">
{labData.conditionTypes.map(conditionType => (
<div key={conditionType.id} className="space-y-1">
<Label className="text-xs font-medium">
{conditionType.name}
{conditionType.unit && (
<span className="ml-1 font-normal text-muted-foreground">
({conditionType.unit})
</span>
)}
</Label>
<Input
value={editingConditions[conditionType.id] || ''}
onChange={e =>
handleConditionChange(
conditionType.id,
e.target.value
)
}
placeholder="Значение"
className="h-7 text-xs"
/>
</div>
))}
</div>
<div className="flex gap-2">
<Button
onClick={handleSaveConditions}
disabled={isSaving}
size="sm"
className="h-7 flex-1 text-xs"
>
{isSaving ? 'Сохранение...' : 'Сохранить'}
</Button>
<Button
variant="outline"
onClick={() => setEditDialogOpen(false)}
size="sm"
className="h-7 flex-1 text-xs"
>
Отмена
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{/* Условия */}
<div className="space-y-1">
{labData.conditionTypes.map(conditionType => {
const conditionValue =
dailyCondition?.conditions[conditionType.id]
return (
<ConditionItem
key={conditionType.id}
name={conditionType.name}
value={conditionValue}
unit={conditionType.unit}
/>
)
})}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,258 @@
import {
ConditionType,
CreateDailyConditionInput,
CreateDailyConditionOutput,
DailyCondition,
DailyConditionsFilters,
GetDailyConditionOutput,
GetDailyConditionsOutput,
GetLaboratoriesOutput,
GetLaboratoryOutput,
Laboratory,
UpdateDailyConditionInput,
UpdateDailyConditionOutput,
} from './types'
// Базовый URL для API (может быть настроен через переменные окружения)
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
// API для лабораторий
export const laboratoriesApi = {
// Получить все лаборатории
getAll: async (): Promise<Laboratory[]> => {
const response = await fetch(`${BASE_URL}/laboratories/`)
if (!response.ok) {
throw new Error('Ошибка загрузки лабораторий')
}
const data: GetLaboratoriesOutput = await response.json()
return data.laboratories
},
// Получить лабораторию по ID
getById: async (id: string): Promise<Laboratory | null> => {
const response = await fetch(`${BASE_URL}/laboratories/${id}`)
if (!response.ok) {
if (response.status === 404) {
return null
}
throw new Error('Ошибка получения лаборатории')
}
const data: GetLaboratoryOutput = await response.json()
return data.laboratory
},
}
// API для ежедневных условий
export const dailyConditionsApi = {
// Получить условия по фильтрам
get: async (
filters: DailyConditionsFilters = {}
): Promise<DailyCondition[]> => {
const searchParams = new URLSearchParams()
if (filters.laboratory_id) {
searchParams.append('laboratory_id', filters.laboratory_id)
}
if (filters.start_date) {
searchParams.append('start_date', filters.start_date)
}
if (filters.end_date) {
searchParams.append('end_date', filters.end_date)
}
const url = searchParams.toString()
? `${BASE_URL}/daily-conditions/?${searchParams}`
: `${BASE_URL}/daily-conditions/`
const response = await fetch(url)
if (!response.ok) {
throw new Error('Ошибка загрузки условий')
}
const data: GetDailyConditionsOutput = await response.json()
return data.daily_conditions
},
// Получить конкретное условие по ID
getById: async (id: string): Promise<DailyCondition | null> => {
const response = await fetch(`${BASE_URL}/daily-conditions/${id}`)
if (!response.ok) {
if (response.status === 404) {
return null
}
throw new Error('Ошибка получения условия')
}
const data: GetDailyConditionOutput = await response.json()
return data.daily_condition
},
// Создать новые условия
create: async (input: CreateDailyConditionInput): Promise<string> => {
const response = await fetch(`${BASE_URL}/daily-conditions/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
})
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ message: 'Ошибка сохранения условий' }))
throw new Error(error.message || 'Ошибка сохранения условий')
}
const data: CreateDailyConditionOutput = await response.json()
return data.id
},
// Обновить условия
update: async (
id: string,
input: UpdateDailyConditionInput
): Promise<string> => {
const response = await fetch(`${BASE_URL}/daily-conditions/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
})
if (!response.ok) {
throw new Error('Ошибка обновления условий')
}
const data: UpdateDailyConditionOutput = await response.json()
return data.id
},
// Удалить условия
delete: async (id: string): Promise<void> => {
const response = await fetch(`${BASE_URL}/daily-conditions/${id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Ошибка удаления условий')
}
},
}
// Комбинированный API для работы с элементом
export const elementApi = {
// Получить лабораторию с её типами условий
getLaboratoryData: async (
laboratoryId: string
): Promise<{
laboratory: Laboratory
conditionTypes: ConditionType[]
}> => {
const laboratory = await laboratoriesApi.getById(laboratoryId)
if (!laboratory) {
throw new Error('Лаборатория не найдена')
}
return {
laboratory,
conditionTypes: laboratory.condition_types,
}
},
// Получить ежедневные условия для конкретной лаборатории и даты
getDailyConditions: async (
laboratoryId: string,
date: string
): Promise<DailyCondition | null> => {
const conditions = await dailyConditionsApi.get({
laboratory_id: laboratoryId,
start_date: date,
end_date: date,
})
return conditions.length > 0 ? conditions[0] : null
},
// Сохранить или обновить условия из элемента
saveElementConditions: async (data: {
laboratoryId: string
date: string
conditions: Record<string, any>
existingConditionId?: string
}): Promise<DailyCondition> => {
const input: CreateDailyConditionInput | UpdateDailyConditionInput = {
laboratory_id: data.laboratoryId,
measurement_date: data.date,
conditions: data.conditions,
}
let conditionId: string
if (data.existingConditionId) {
// Обновляем существующие условия
conditionId = await dailyConditionsApi.update(
data.existingConditionId,
input as UpdateDailyConditionInput
)
} else {
// Создаем новые условия
conditionId = await dailyConditionsApi.create(
input as CreateDailyConditionInput
)
}
// Получаем обновленные данные
const savedCondition = await dailyConditionsApi.getById(conditionId)
if (!savedCondition) {
throw new Error('Ошибка получения сохраненных условий')
}
return savedCondition
},
}
// Вспомогательные функции
export const verificationConditionsUtils = {
// Валидация значения условия
validateConditionValue: (
value: any,
dataType: string = 'string',
minValue?: number,
maxValue?: number
): { isValid: boolean; error?: string } => {
if (dataType === 'number') {
const numValue = parseFloat(value)
if (isNaN(numValue)) {
return { isValid: false, error: 'Должно быть числом' }
}
if (minValue !== undefined && numValue < minValue) {
return { isValid: false, error: `Минимальное значение: ${minValue}` }
}
if (maxValue !== undefined && numValue > maxValue) {
return { isValid: false, error: `Максимальное значение: ${maxValue}` }
}
}
return { isValid: true }
},
// Форматирование значения с единицей измерения
formatValueWithUnit: (value: any, unit?: string): string => {
if (value === undefined || value === null || value === '') {
return '-'
}
return unit ? `${value} ${unit}` : value.toString()
},
// Получение ключа условия для хранения в conditions JSON (обычно используется ID типа условия)
getConditionKey: (conditionTypeId: string): string => {
return conditionTypeId
},
// Генерация ключа на основе названия условия (для обратной совместимости)
generateConditionKey: (conditionTypeName: string): string => {
return conditionTypeName
.toLowerCase()
.replace(/\s+/g, '_')
.replace(/[^\w]/g, '')
},
// Проверка, является ли строка валидным UUID
isValidUUID: (str: string): boolean => {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
return uuidRegex.test(str)
},
}

View File

@@ -0,0 +1,147 @@
import { ElementDefinition } from '@/entity/element/model/interface'
import { CellTarget } from '@/type/template'
import { Building2 } from 'lucide-react'
import {
VerificationConditionsConfig,
VerificationConditionsValue,
} from './types'
import { VerificationConditionsEditor } from './VerificationConditionsEditor'
import { VerificationConditionsPreview } from './VerificationConditionsPreview'
import { VerificationConditionsRender } from './VerificationConditionsRender'
// Форматирование даты как в функции ДАТАПРОТОКОЛА
const formatVerificationDate = (dateStr: string): string => {
try {
const months = [
'',
'января',
'февраля',
'марта',
'апреля',
'мая',
'июня',
'июля',
'августа',
'сентября',
'октября',
'ноября',
'декабря',
]
const dateObj = new Date(dateStr)
if (isNaN(dateObj.getTime())) {
return 'Неверный формат даты'
}
const day = dateObj.getDate().toString().padStart(2, '0')
const month = months[dateObj.getMonth() + 1]
const year = dateObj.getFullYear()
return `${day} ${month} ${year}`
} catch (e) {
return 'Неверный формат даты'
}
}
export const verificationConditionsElementDefinition: ElementDefinition<
VerificationConditionsConfig & { targetCells?: CellTarget[] },
VerificationConditionsValue
> = {
type: 'verification_conditions',
label: 'Условия поверки',
icon: <Building2 className="h-4 w-4" />,
version: 1,
defaultConfig: {
selectedLaboratoryId: undefined,
selectedDate: undefined,
conditionMappings: [],
showUnits: true,
targetCells: [],
id: '',
},
// Компоненты
Editor: VerificationConditionsEditor,
Preview: VerificationConditionsPreview,
Render: VerificationConditionsRender,
// Получение начальных значений из конфигурации с учетом localStorage
getInitialValue: config => {
let initialDate =
config.selectedDate || new Date().toISOString().split('T')[0]
// Попытка загрузить дату из localStorage
if (config.id) {
try {
const key = `verification_conditions_date_${config.id}`
const savedDate = localStorage.getItem(key)
if (savedDate) {
initialDate = savedDate
}
} catch (error) {
console.warn('Failed to get date from localStorage:', error)
}
}
return {
date: initialDate,
conditions: {},
}
},
// Получение ячеек для записи данных на основе маппинга условий
mapToCells: (config): CellTarget[] => {
// Возвращаем только те targetCells, которые замаплены к условиям
if (!config.conditionMappings || !config.targetCells) {
return []
}
return config.conditionMappings
.map(mapping => config.targetCells?.[mapping.cellIndex])
.filter(Boolean) as CellTarget[]
},
// Получение значения для записи в ячейки на основе реального маппинга
mapToCellValues: (
config,
value: VerificationConditionsValue
): Array<{ target: CellTarget; value: any }> => {
if (!config.conditionMappings || !config.targetCells) {
return []
}
return config.conditionMappings
.map(mapping => {
const targetCell = config.targetCells?.[mapping.cellIndex]
if (!targetCell) return null
// Обработка даты поверки
if (mapping.conditionKey === 'verification_date') {
if (value.date) {
return {
target: targetCell,
value: formatVerificationDate(value.date),
}
}
return null
}
// Обработка остальных условий
const conditionValue = value.conditions?.[mapping.conditionKey]
if (
conditionValue !== undefined &&
conditionValue !== null &&
String(conditionValue).trim() !== ''
) {
return {
target: targetCell,
value: conditionValue,
}
}
return null
})
.filter(Boolean) as Array<{ target: CellTarget; value: any }>
},
}

View File

@@ -0,0 +1,218 @@
import { useCallback, useEffect, useState } from 'react'
import { dailyConditionsApi, elementApi, laboratoriesApi } from './api'
import { ConditionType, DailyCondition, Laboratory } from './types'
// Хук для загрузки лабораторий
export function useLaboratories() {
const [data, setData] = useState<Laboratory[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isError, setIsError] = useState(false)
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true)
setIsError(false)
const laboratories = await laboratoriesApi.getAll()
setData(laboratories)
} catch (error) {
setIsError(true)
console.error('Ошибка загрузки лабораторий:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [])
return { data, isLoading, isError }
}
// Хук для работы с ежедневными условиями
export function useDailyConditions(laboratoryId?: string, date?: string) {
const [data, setData] = useState<DailyCondition | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
if (!laboratoryId || !date) {
setData(null)
return
}
const loadData = async () => {
try {
setIsLoading(true)
setIsError(false)
const conditions = await elementApi.getDailyConditions(
laboratoryId,
date
)
setData(conditions)
} catch (error) {
setIsError(true)
console.error('Ошибка загрузки ежедневных условий:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [laboratoryId, date])
const saveConditions = useCallback(
async (conditions: Record<string, any>) => {
if (!laboratoryId || !date) {
throw new Error('Не указаны лаборатория или дата')
}
try {
setIsSaving(true)
const savedCondition = await elementApi.saveElementConditions({
laboratoryId,
date,
conditions,
existingConditionId: data?.id,
})
setData(savedCondition)
return savedCondition
} catch (error) {
console.error('Ошибка сохранения условий:', error)
throw error
} finally {
setIsSaving(false)
}
},
[laboratoryId, date, data?.id]
)
return {
data,
isLoading,
isError,
isSaving,
saveConditions,
}
}
// Хук для получения данных лаборатории с типами условий
export function useLaboratoryConditions(laboratoryId?: string) {
const [data, setData] = useState<{
laboratory: Laboratory
conditionTypes: ConditionType[]
} | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
useEffect(() => {
if (!laboratoryId) {
setData(null)
return
}
const loadData = async () => {
try {
setIsLoading(true)
setIsError(false)
const result = await elementApi.getLaboratoryData(laboratoryId)
setData(result)
} catch (error) {
setIsError(true)
console.error('Ошибка загрузки условий лаборатории:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [laboratoryId])
return { data, isLoading, isError }
}
// Хук для получения конкретной лаборатории
export function useLaboratory(laboratoryId?: string) {
const [data, setData] = useState<Laboratory | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
useEffect(() => {
if (!laboratoryId) {
setData(null)
return
}
const loadData = async () => {
try {
setIsLoading(true)
setIsError(false)
const laboratory = await laboratoriesApi.getById(laboratoryId)
setData(laboratory)
} catch (error) {
setIsError(true)
console.error('Ошибка загрузки лаборатории:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [laboratoryId])
return { data, isLoading, isError }
}
// Хук для управления ежедневными условиями с расширенным функционалом
export function useDailyConditionsManager() {
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const loadConditions = useCallback(
async (filters: {
laboratoryId?: string
startDate?: string
endDate?: string
}) => {
try {
setIsLoading(true)
setIsError(false)
const conditions = await dailyConditionsApi.get({
laboratory_id: filters.laboratoryId,
start_date: filters.startDate,
end_date: filters.endDate,
})
return conditions
} catch (error) {
setIsError(true)
console.error('Ошибка загрузки условий:', error)
throw error
} finally {
setIsLoading(false)
}
},
[]
)
const deleteConditions = useCallback(async (conditionId: string) => {
try {
setIsLoading(true)
setIsError(false)
await dailyConditionsApi.delete(conditionId)
} catch (error) {
setIsError(true)
console.error('Ошибка удаления условий:', error)
throw error
} finally {
setIsLoading(false)
}
}, [])
return {
isLoading,
isError,
loadConditions,
deleteConditions,
}
}

View File

@@ -0,0 +1,8 @@
export { verificationConditionsElementDefinition as verificationConditionsDefinition } from './definition'
export { VerificationConditionsEditor } from './VerificationConditionsEditor'
export { VerificationConditionsPreview } from './VerificationConditionsPreview'
export { VerificationConditionsRender } from './VerificationConditionsRender'
export * from './api'
export * from './hooks'
export * from './types'

View File

@@ -0,0 +1,2 @@
// Флаг для переключения между mock и реальными данными
export const USE_MOCK_DATA = false

View File

@@ -0,0 +1,105 @@
// Типы для элемента "Условия поверки" на основе новой API схемы
// Маппинг условия в ячейку
export interface ConditionMapping {
conditionKey: string // ключ условия (temperature, humidity, etc.)
conditionName: string // название условия для отображения
cellIndex: number // индекс целевой ячейки
}
// Конфигурация элемента
export interface VerificationConditionsConfig {
// Выбранная лаборатория (UUID)
selectedLaboratoryId?: string
// Выбранная дата
selectedDate?: string
// Маппинг условий в ячейки (включая дату поверки)
conditionMappings?: ConditionMapping[]
// Показывать ли единицы измерения
showUnits?: boolean
// Целевые ячейки
targetCells?: any[]
// Идентификатор для совместимости с TemplateElement
id?: string
}
// Значение элемента
export interface VerificationConditionsValue {
date: string // дата в формате YYYY-MM-DD
conditions: Record<string, string> // условия поверки {temperature: "20", humidity: "50", ...}
}
// Тип условия из API
export interface ConditionType {
id: string
name: string
unit: string
created_at: string
updated_at: string
deleted_at: string | null
}
// Лаборатория из API
export interface Laboratory {
id: string
name: string
condition_types: ConditionType[]
created_at: string
updated_at: string
deleted_at: string | null
}
// Ежедневное условие из API
export interface DailyCondition {
id: string
laboratory_id: string
measurement_date: string
conditions: Record<string, any>
created_at: string
updated_at: string
deleted_at: string | null
}
// Типы для API запросов/ответов
export interface GetLaboratoriesOutput {
laboratories: Laboratory[]
}
export interface GetLaboratoryOutput {
laboratory: Laboratory | null
}
export interface CreateDailyConditionInput {
laboratory_id: string
measurement_date: string
conditions: Record<string, any>
}
export interface CreateDailyConditionOutput {
id: string
}
export interface UpdateDailyConditionInput {
laboratory_id?: string | null
measurement_date?: string | null
conditions?: Record<string, any> | null
}
export interface UpdateDailyConditionOutput {
id: string
}
export interface GetDailyConditionsOutput {
daily_conditions: DailyCondition[]
}
export interface GetDailyConditionOutput {
daily_condition: DailyCondition | null
}
// Параметры фильтрации для получения ежедневных условий
export interface DailyConditionsFilters {
laboratory_id?: string
start_date?: string
end_date?: string
}

View File

@@ -0,0 +1,78 @@
import { buttonGroupDefinition } from '@/entity/element/model/implementations/ButtonGroup'
import { selectDefinition } from '@/entity/element/model/implementations/SelectElement'
import { standardsDefinition } from '@/entity/element/model/implementations/StandardsElement/definition'
import { textDefinition } from '@/entity/element/model/implementations/TextElement'
import { verificationConditionsElementDefinition as verificationConditionsDefinition } from '@/entity/element/model/implementations/VerificationConditionsElement/definition'
import { CellTarget } from '@/type/template'
import { ReactNode } from 'react'
export interface ElementDefinition<Config = any, Value = any> {
type: ElementType
label: string
icon: ReactNode
/* React-компоненты */
Editor: React.FC<{ config: Config; onChange(c: Config): void }>
Preview: React.FC<{ config: Config }>
Render: React.FC<{ config: Config; value: Value; onChange?(v: Value): void }>
/* бизнес-логика */
defaultConfig: Config
getInitialValue?(config: Config): Value // получение начальных значений из конфигурации
mapToCells(config: Config): CellTarget[] // куда пишем данные (для отображения)
mapToCellValues(
config: Config,
value: Value
): Array<{ target: CellTarget; value: any }> // готовые пары ячейка-значение для записи
/* мета */
version: number
}
export const elementRegistry: Record<ElementType, ElementDefinition> = {} as any
// Функция для регистрации элемента
export function registerElement<T extends ElementType>(
type: T,
definition: ElementDefinition
) {
elementRegistry[type] = definition
}
// Получить все доступные типы элементов
export function getAvailableElementTypes(): Array<{
type: ElementType
label: string
icon: ReactNode
}> {
return Object.values(elementRegistry).map(({ type, label, icon }) => ({
type,
label,
icon,
}))
}
// Получить определение элемента по типу
export function getElementDefinition(
type: ElementType
): ElementDefinition | undefined {
return elementRegistry[type]
}
export const elementDefinitions = {
text: textDefinition,
select: selectDefinition,
standards: standardsDefinition,
verification_conditions: verificationConditionsDefinition,
button_group: buttonGroupDefinition,
} as const
export type ElementType = keyof typeof elementDefinitions
export function initializeElementRegistry() {
registerElement('text', textDefinition)
registerElement('select', selectDefinition)
registerElement('button_group', buttonGroupDefinition)
registerElement('standards', standardsDefinition)
registerElement('verification_conditions', verificationConditionsDefinition)
}

View File

@@ -0,0 +1,33 @@
import { CellTarget } from '@/type/template'
import { Grid } from 'lucide-react'
interface CellsBadgeProps {
targetCells: CellTarget[]
className?: string
}
export const CellsBadge: React.FC<CellsBadgeProps> = ({
targetCells,
className = 'absolute right-0 top-0',
}) => {
if (!targetCells || targetCells.length === 0) {
return null
}
return (
<div className={`group/cells ${className}`}>
<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>
{targetCells.map((cell, i) => (
<div key={i} className="font-mono">
{cell.sheet}!{cell.cell}
{cell.displayName && (
<span className="ml-1 text-gray-300">({cell.displayName})</span>
)}
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,7 @@
export const ExtraSettingsBadge = () => {
return (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют
</div>
)
}

View File

@@ -0,0 +1,338 @@
export interface CreateTemplateInput {
name: string
description?: string
elements: Record<string, any>
attributes?: Array<{
attribute_id: string
value: string | null
}>
}
export interface CreateTemplateOutput {
id: string
}
export interface GetTemplatesOutput {
templates: ApiTemplateWithAttributes[]
}
export interface GetTemplateOutput {
template: ApiTemplate | null
}
export interface GetTemplateWithAttributesOutput {
template: ApiTemplateWithAttributes | null
}
export interface PatchTemplateInput {
name?: string | null
description?: string | null
elements?: Record<string, any> | null
attributes?: Array<{
attribute_id: string
value: string | null
}> | null
}
export interface PatchTemplateOutput {
id: string
}
export interface ApiTemplate {
id: string
name: string
description?: string | null
elements: Record<string, any>
created_at: string
updated_at: string
deleted_at?: string | null
}
export interface ApiTemplateWithAttributes extends ApiTemplate {
attributes: Array<{
attribute_id: string
attribute_name: string
is_required: boolean
value: string | null
created_at: string
updated_at: string
}>
}
// Импортируем типы для преобразования
import { getDefaultLayoutSettings, migrateTemplateElement } from '@/lib/utils'
import {
Template,
TemplateAttributeDetail,
TemplateElement,
} from '@/type/template'
// Утилитарные функции для преобразования данных
// Преобразование API шаблона в внутренний формат
export function apiTemplateToTemplate(apiTemplate: ApiTemplate): Template {
// Преобразуем elements из Record<string, any> в TemplateElement[]
const elements: TemplateElement[] = Object.values(apiTemplate.elements || {})
.map(el => migrateTemplateElement(el))
.sort((a, b) => (a.order || 0) - (b.order || 0))
return {
id: apiTemplate.id,
name: apiTemplate.name,
description: apiTemplate.description || undefined,
elements,
layoutSettings: getDefaultLayoutSettings(),
createdAt: new Date(apiTemplate.created_at),
updatedAt: new Date(apiTemplate.updated_at),
}
}
// Преобразование API шаблона с атрибутами в внутренний формат
export function apiTemplateWithAttributesToTemplate(
apiTemplate: ApiTemplateWithAttributes
): Template & { attributes: TemplateAttributeDetail[] } {
const baseTemplate = apiTemplateToTemplate(apiTemplate)
return {
...baseTemplate,
attributes: apiTemplate.attributes.map(attr => ({
attribute_id: attr.attribute_id,
attribute_name: attr.attribute_name,
is_required: attr.is_required,
value: attr.value,
created_at: attr.created_at,
updated_at: attr.updated_at,
})),
}
}
// Преобразование внутреннего шаблона в API формат для создания
export function templateToCreateInput(
template: Omit<Template, 'id' | 'createdAt' | 'updatedAt'>
): CreateTemplateInput {
// Преобразуем элементы в Record<string, any>
const elements: Record<string, any> = {}
template.elements.forEach((element, index) => {
elements[element.id] = element
})
return {
name: template.name,
description: template.description,
elements,
}
}
// Преобразование внутреннего шаблона в API формат для обновления
export function templateToPatchInput(template: Template): PatchTemplateInput {
const elements: Record<string, any> = {}
template.elements.forEach(element => {
elements[element.id] = element
})
return {
name: template.name,
description: template.description,
elements,
// attributes будут добавлены отдельно в TemplateContext при необходимости
}
}
// API создание шаблона
export async function createTemplateApi(
template: CreateTemplateInput
): Promise<CreateTemplateOutput> {
try {
const response = await fetch('/api/templates/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(template),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: CreateTemplateOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при создании шаблона:', error)
throw new Error('Ошибка при создании шаблона на сервере')
}
}
// API получение всех шаблонов
export async function getTemplatesApi(): Promise<GetTemplatesOutput> {
try {
const response = await fetch('/api/templates/', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: GetTemplatesOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при получении шаблонов:', error)
throw new Error('Ошибка при получении шаблонов с сервера')
}
}
// API получение конкретного шаблона
export async function getTemplateApi(
templateId: string
): Promise<GetTemplateOutput> {
try {
const response = await fetch(`/api/templates/${templateId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: GetTemplateOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при получении шаблона:', error)
throw new Error('Ошибка при получении шаблона с сервера')
}
}
// API получение шаблона с атрибутами
export async function getTemplateWithAttributesApi(
templateId: string
): Promise<GetTemplateWithAttributesOutput> {
try {
const response = await fetch(`/api/templates/${templateId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: GetTemplateWithAttributesOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при получении шаблона с атрибутами:', error)
throw new Error('Ошибка при получении шаблона с атрибутами с сервера')
}
}
// API обновление шаблона
export async function patchTemplateApi(
templateId: string,
template: PatchTemplateInput
): Promise<PatchTemplateOutput> {
try {
const response = await fetch(`/api/templates/${templateId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(template),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: PatchTemplateOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при обновлении шаблона:', error)
throw new Error('Ошибка при обновлении шаблона на сервере')
}
}
// Алиас для удобства использования
export async function updateTemplate(
template: Template
): Promise<PatchTemplateOutput> {
const patchInput = templateToPatchInput(template)
return patchTemplateApi(template.id, patchInput)
}
// Интерфейсы для копирования шаблона
export interface CopyTemplateInput {
template_id: string
new_name: string
new_description?: string | null
new_attributes?: Array<{
attribute_id: string
value: string | null
}> | null
}
export interface CopyTemplateOutput {
new_template_id: string
}
// Интерфейсы для удаления шаблона
export interface DeleteTemplateOutput {
success: boolean
template_id: string
}
// API копирование шаблона
export async function copyTemplateApi(
input: CopyTemplateInput
): Promise<CopyTemplateOutput> {
try {
const response = await fetch('/api/templates/copy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: CopyTemplateOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при копировании шаблона:', error)
throw new Error('Ошибка при копировании шаблона на сервере')
}
}
// API удаление шаблона
export async function deleteTemplateApi(
templateId: string
): Promise<DeleteTemplateOutput> {
try {
const response = await fetch(`/api/templates/${templateId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: DeleteTemplateOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при удалении шаблона:', error)
throw new Error('Ошибка при удалении шаблона на сервере')
}
}

View File

@@ -0,0 +1,289 @@
import { Attribute, TemplateAttributeDetail } from '@/type/template'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import React, { createContext, ReactNode, useContext, useMemo } from 'react'
interface CategoryContextType {
attributes: Attribute[]
isLoading: boolean
error: Error | null
refetchAttributes: () => void
addAttribute: (data: {
name: string
is_required: boolean
}) => Promise<Attribute>
updateAttribute: (attribute: Attribute) => Promise<Attribute>
deleteAttribute: (id: string) => Promise<string>
// Утилитарные функции для работы с атрибутами
getRequiredAttributes: () => Attribute[]
getOptionalAttributes: () => Attribute[]
getAttributeById: (id: string) => Attribute | undefined
}
interface TemplateAttributesContextType {
getTemplateAttributes: (
templateId: string
) => Promise<TemplateAttributeDetail[]>
setTemplateAttributes: (
templateId: string,
attributes: Array<{ attribute_id: string; value: string | null }>
) => Promise<
Array<{ template_id: string; attribute_id: string; value: string | null }>
>
}
const CategoryContext = createContext<CategoryContextType | undefined>(
undefined
)
const TemplateAttributesContext = createContext<
TemplateAttributesContextType | undefined
>(undefined)
// API функции для работы с атрибутами
const fetchAttributes = async (): Promise<Attribute[]> => {
const response = await fetch('/api/attributes/')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
return data.attributes || []
}
const createAttribute = async (attribute: {
name: string
is_required: boolean
}): Promise<Attribute> => {
const response = await fetch('/api/attributes/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(attribute),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result = await response.json()
// После создания получаем полную информацию об атрибуте
// так как API возвращает только id
const attributesResponse = await fetch('/api/attributes/')
if (!attributesResponse.ok) {
throw new Error(`HTTP error! status: ${attributesResponse.status}`)
}
const attributesData = await attributesResponse.json()
const newAttribute = attributesData.attributes.find(
(attr: Attribute) => attr.id === result.id
)
if (!newAttribute) {
throw new Error('Created attribute not found')
}
return newAttribute
}
const updateAttribute = async (attribute: Attribute): Promise<Attribute> => {
// Поскольку в API нет эндпоинта для обновления атрибутов,
// возвращаем переданный атрибут как есть
// TODO: Добавить PATCH /attributes/{id} в API
return attribute
}
const deleteAttribute = async (id: string): Promise<string> => {
// Поскольку в API нет эндпоинта для удаления атрибутов,
// просто возвращаем id
// TODO: Добавить DELETE /attributes/{id} в API
return id
}
// API функции для работы с атрибутами шаблонов
const fetchTemplateAttributes = async (
templateId: string
): Promise<TemplateAttributeDetail[]> => {
const response = await fetch(`/api/templates/${templateId}/attributes`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
return data.attributes || []
}
const setTemplateAttributes = async (
templateId: string,
attributes: Array<{ attribute_id: string; value: string | null }>
): Promise<
Array<{ template_id: string; attribute_id: string; value: string | null }>
> => {
const response = await fetch(`/api/templates/${templateId}/attributes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ attributes }),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return response.json()
}
export const CategoryProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const client = useQueryClient()
const {
data: attributes = [],
isLoading,
error,
refetch,
} = useQuery<Attribute[], Error>({
queryKey: ['attributes'],
queryFn: fetchAttributes,
})
const createMutation = useMutation({
mutationFn: createAttribute,
onSuccess: () => {
client.invalidateQueries({ queryKey: ['attributes'] })
},
})
const updateMutation = useMutation({
mutationFn: updateAttribute,
onSuccess: updated => {
client.setQueryData<Attribute[]>(
['attributes'],
old => old?.map(attr => (attr.id === updated.id ? updated : attr)) ?? []
)
},
})
const deleteMutation = useMutation({
mutationFn: deleteAttribute,
onSuccess: deletedId => {
client.setQueryData<Attribute[]>(
['attributes'],
old => old?.filter(attr => attr.id !== deletedId) ?? []
)
},
})
// Утилитарные функции
const getRequiredAttributes = () =>
attributes.filter(attr => attr.is_required)
const getOptionalAttributes = () =>
attributes.filter(attr => !attr.is_required)
const getAttributeById = (id: string) =>
attributes.find(attr => attr.id === id)
const categoryValue = useMemo(
() => ({
attributes,
isLoading:
isLoading ||
createMutation.isPending ||
updateMutation.isPending ||
deleteMutation.isPending,
error:
error ||
createMutation.error ||
updateMutation.error ||
deleteMutation.error,
refetchAttributes: refetch,
addAttribute: createMutation.mutateAsync,
updateAttribute: updateMutation.mutateAsync,
deleteAttribute: deleteMutation.mutateAsync,
getRequiredAttributes,
getOptionalAttributes,
getAttributeById,
}),
[
attributes,
isLoading,
error,
createMutation,
updateMutation,
deleteMutation,
refetch,
]
)
const templateAttributesValue = useMemo(
() => ({
getTemplateAttributes: fetchTemplateAttributes,
setTemplateAttributes: setTemplateAttributes,
}),
[]
)
return (
<CategoryContext.Provider value={categoryValue}>
<TemplateAttributesContext.Provider value={templateAttributesValue}>
{children}
</TemplateAttributesContext.Provider>
</CategoryContext.Provider>
)
}
export const useCategoryContext = (): CategoryContextType => {
const context = useContext(CategoryContext)
if (!context) {
throw new Error('useCategoryContext must be used within a CategoryProvider')
}
return context
}
export const useTemplateAttributesContext =
(): TemplateAttributesContextType => {
const context = useContext(TemplateAttributesContext)
if (!context) {
throw new Error(
'useTemplateAttributesContext must be used within a CategoryProvider'
)
}
return context
}
// Хук для работы с атрибутами конкретного шаблона
export const useTemplateAttributes = (templateId: string) => {
const client = useQueryClient()
const { getTemplateAttributes, setTemplateAttributes } =
useTemplateAttributesContext()
const {
data: templateAttributes = [],
isLoading,
error,
refetch,
} = useQuery<TemplateAttributeDetail[], Error>({
queryKey: ['templateAttributes', templateId],
queryFn: () => getTemplateAttributes(templateId),
enabled: !!templateId,
})
const setAttributesMutation = useMutation({
mutationFn: (
attributes: Array<{ attribute_id: string; value: string | null }>
) => setTemplateAttributes(templateId, attributes),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['templateAttributes', templateId] })
},
})
return {
templateAttributes,
isLoading: isLoading || setAttributesMutation.isPending,
error: error || setAttributesMutation.error,
refetch,
setAttributes: setAttributesMutation.mutateAsync,
}
}
// Для обратной совместимости
export const useCategoryFields = useCategoryContext
export default CategoryProvider

View File

@@ -0,0 +1,250 @@
import {
apiTemplateToTemplate,
apiTemplateWithAttributesToTemplate,
copyTemplateApi,
CopyTemplateInput,
createTemplateApi,
deleteTemplateApi,
getTemplateApi,
getTemplatesApi,
patchTemplateApi,
templateToCreateInput,
templateToPatchInput,
} from '@/entity/template/api/templateApiService'
import { Template } from '@/type/template'
import {
QueryClient,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import React, { createContext, ReactNode, useContext, useMemo } from 'react'
export const queryClient = new QueryClient()
interface TemplateContextType {
templates: Template[]
isLoading: boolean
error: Error | null
refetchTemplates: () => void
addTemplate: (
data: Omit<Template, 'id' | 'createdAt' | 'updatedAt'>,
attributes?: Array<{ attributeId: string; value: string | null }>
) => Promise<Template>
updateTemplate: (
template: Template,
attributes?: Array<{ attributeId: string; value: string | null }>
) => Promise<Template>
deleteTemplate: (id: string) => Promise<string>
copyTemplate: (
templateId: string,
newName: string,
newDescription?: string,
attributes?: Array<{ attributeId: string; value: string | null }>
) => Promise<Template>
}
const TemplateContext = createContext<TemplateContextType | undefined>(
undefined
)
export const TemplateProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const client = useQueryClient()
const {
data: templates = [],
status: listStatus,
error: errorList,
refetch,
} = useQuery<Template[], Error>({
queryKey: ['templates'],
queryFn: async () => {
const { templates: apiList } = await getTemplatesApi()
return apiList.map(apiTemplateWithAttributesToTemplate)
},
})
const createMutation = useMutation({
mutationFn: async ({
template,
attributes,
}: {
template: Omit<Template, 'id' | 'createdAt' | 'updatedAt'>
attributes?: Array<{ attributeId: string; value: string | null }>
}) => {
const input = templateToCreateInput(template)
if (attributes && attributes.length > 0) {
input.attributes = attributes.map(attr => ({
attribute_id: attr.attributeId,
value: attr.value,
}))
}
const { id } = await createTemplateApi(input)
const { template: apiTpl } = await getTemplateApi(id)
if (!apiTpl) throw new Error('Template not found')
return apiTemplateToTemplate(apiTpl)
},
onSuccess: () => {
client.invalidateQueries({ queryKey: ['templates'] })
},
})
const updateMutation = useMutation({
mutationFn: async ({
template,
attributes,
}: {
template: Template
attributes?: Array<{ attributeId: string; value: string | null }>
}) => {
const input = templateToPatchInput(template)
if (attributes) {
input.attributes = attributes.map(attr => ({
attribute_id: attr.attributeId,
value: attr.value,
}))
}
await patchTemplateApi(template.id, input)
return template
},
onSuccess: updated => {
client.setQueryData<Template[]>(
['templates'],
old =>
old?.map((t: Template) => (t.id === updated.id ? updated : t)) ?? []
)
},
})
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
const result = await deleteTemplateApi(id)
if (!result.success) {
throw new Error('Не удалось удалить шаблон')
}
return id
},
onSuccess: id => {
client.setQueryData<Template[]>(
['templates'],
old => old?.filter((t: Template) => t.id !== id) ?? []
)
},
})
const copyMutation = useMutation({
mutationFn: async ({
templateId,
newName,
newDescription,
attributes,
}: {
templateId: string
newName: string
newDescription?: string
attributes?: Array<{ attributeId: string; value: string | null }>
}) => {
const input: CopyTemplateInput = {
template_id: templateId,
new_name: newName,
new_description: newDescription || null,
new_attributes:
attributes?.map(attr => ({
attribute_id: attr.attributeId,
value: attr.value,
})) || null,
}
const { new_template_id } = await copyTemplateApi(input)
const { template: apiTpl } = await getTemplateApi(new_template_id)
if (!apiTpl) throw new Error('Copied template not found')
return apiTemplateToTemplate(apiTpl)
},
onSuccess: () => {
client.invalidateQueries({ queryKey: ['templates'] })
},
})
const value = useMemo(
() => ({
templates,
isLoading:
listStatus === 'pending' ||
createMutation.status === 'pending' ||
updateMutation.status === 'pending' ||
deleteMutation.status === 'pending' ||
copyMutation.status === 'pending',
error:
(errorList as Error) ||
(createMutation.error as Error) ||
(updateMutation.error as Error) ||
(deleteMutation.error as Error) ||
(copyMutation.error as Error) ||
null,
refetchTemplates: refetch,
addTemplate: async (
template: Omit<Template, 'id' | 'createdAt' | 'updatedAt'>,
attributes?: Array<{ attributeId: string; value: string | null }>
) =>
createMutation.mutateAsync({
template,
attributes,
}),
updateTemplate: async (
template: Template,
attributes?: Array<{ attributeId: string; value: string | null }>
) =>
updateMutation.mutateAsync({
template,
attributes,
}),
deleteTemplate: deleteMutation.mutateAsync,
copyTemplate: async (
templateId: string,
newName: string,
newDescription?: string,
attributes?: Array<{ attributeId: string; value: string | null }>
) =>
copyMutation.mutateAsync({
templateId,
newName,
newDescription,
attributes,
}),
}),
[
templates,
listStatus,
errorList,
createMutation.status,
createMutation.error,
updateMutation.status,
updateMutation.error,
deleteMutation.status,
deleteMutation.error,
copyMutation.status,
copyMutation.error,
refetch,
createMutation.mutateAsync,
updateMutation.mutateAsync,
deleteMutation.mutateAsync,
copyMutation.mutateAsync,
]
)
return (
<TemplateContext.Provider value={value}>
{children}
</TemplateContext.Provider>
)
}
export const useTemplateContext = (): TemplateContextType => {
const ctx = useContext(TemplateContext)
if (!ctx)
throw new Error('useTemplateContext must be used within TemplateProvider')
return ctx
}
export default TemplateProvider