ручной рефакторинг
This commit is contained in:
238
src/entity/element/model/implementations/ButtonGroup.tsx
Normal file
238
src/entity/element/model/implementations/ButtonGroup.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
229
src/entity/element/model/implementations/SelectElement.tsx
Normal file
229
src/entity/element/model/implementations/SelectElement.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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] || '',
|
||||
}))
|
||||
},
|
||||
}
|
||||
@@ -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}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
403
src/entity/element/model/implementations/TextElement.tsx
Normal file
403
src/entity/element/model/implementations/TextElement.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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 }>
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -0,0 +1,2 @@
|
||||
// Флаг для переключения между mock и реальными данными
|
||||
export const USE_MOCK_DATA = false
|
||||
@@ -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
|
||||
}
|
||||
78
src/entity/element/model/interface.ts
Normal file
78
src/entity/element/model/interface.ts
Normal 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)
|
||||
}
|
||||
33
src/entity/element/ui/cellsBadge.tsx
Normal file
33
src/entity/element/ui/cellsBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
src/entity/element/ui/extraSettingsBadge.tsx
Normal file
7
src/entity/element/ui/extraSettingsBadge.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export const ExtraSettingsBadge = () => {
|
||||
return (
|
||||
<div className="text-sm text-gray-600">
|
||||
Дополнительные настройки отсутствуют
|
||||
</div>
|
||||
)
|
||||
}
|
||||
338
src/entity/template/api/templateApiService.ts
Normal file
338
src/entity/template/api/templateApiService.ts
Normal 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('Ошибка при удалении шаблона на сервере')
|
||||
}
|
||||
}
|
||||
289
src/entity/template/model/CategoryContext.tsx
Normal file
289
src/entity/template/model/CategoryContext.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { Attribute, TemplateAttributeDetail } from '@/type/template'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import React, { createContext, ReactNode, useContext, useMemo } from 'react'
|
||||
|
||||
interface CategoryContextType {
|
||||
attributes: Attribute[]
|
||||
isLoading: boolean
|
||||
error: Error | null
|
||||
refetchAttributes: () => void
|
||||
addAttribute: (data: {
|
||||
name: string
|
||||
is_required: boolean
|
||||
}) => Promise<Attribute>
|
||||
updateAttribute: (attribute: Attribute) => Promise<Attribute>
|
||||
deleteAttribute: (id: string) => Promise<string>
|
||||
// Утилитарные функции для работы с атрибутами
|
||||
getRequiredAttributes: () => Attribute[]
|
||||
getOptionalAttributes: () => Attribute[]
|
||||
getAttributeById: (id: string) => Attribute | undefined
|
||||
}
|
||||
|
||||
interface TemplateAttributesContextType {
|
||||
getTemplateAttributes: (
|
||||
templateId: string
|
||||
) => Promise<TemplateAttributeDetail[]>
|
||||
setTemplateAttributes: (
|
||||
templateId: string,
|
||||
attributes: Array<{ attribute_id: string; value: string | null }>
|
||||
) => Promise<
|
||||
Array<{ template_id: string; attribute_id: string; value: string | null }>
|
||||
>
|
||||
}
|
||||
|
||||
const CategoryContext = createContext<CategoryContextType | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const TemplateAttributesContext = createContext<
|
||||
TemplateAttributesContextType | undefined
|
||||
>(undefined)
|
||||
|
||||
// API функции для работы с атрибутами
|
||||
const fetchAttributes = async (): Promise<Attribute[]> => {
|
||||
const response = await fetch('/api/attributes/')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.attributes || []
|
||||
}
|
||||
|
||||
const createAttribute = async (attribute: {
|
||||
name: string
|
||||
is_required: boolean
|
||||
}): Promise<Attribute> => {
|
||||
const response = await fetch('/api/attributes/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(attribute),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const result = await response.json()
|
||||
|
||||
// После создания получаем полную информацию об атрибуте
|
||||
// так как API возвращает только id
|
||||
const attributesResponse = await fetch('/api/attributes/')
|
||||
if (!attributesResponse.ok) {
|
||||
throw new Error(`HTTP error! status: ${attributesResponse.status}`)
|
||||
}
|
||||
const attributesData = await attributesResponse.json()
|
||||
const newAttribute = attributesData.attributes.find(
|
||||
(attr: Attribute) => attr.id === result.id
|
||||
)
|
||||
|
||||
if (!newAttribute) {
|
||||
throw new Error('Created attribute not found')
|
||||
}
|
||||
|
||||
return newAttribute
|
||||
}
|
||||
|
||||
const updateAttribute = async (attribute: Attribute): Promise<Attribute> => {
|
||||
// Поскольку в API нет эндпоинта для обновления атрибутов,
|
||||
// возвращаем переданный атрибут как есть
|
||||
// TODO: Добавить PATCH /attributes/{id} в API
|
||||
return attribute
|
||||
}
|
||||
|
||||
const deleteAttribute = async (id: string): Promise<string> => {
|
||||
// Поскольку в API нет эндпоинта для удаления атрибутов,
|
||||
// просто возвращаем id
|
||||
// TODO: Добавить DELETE /attributes/{id} в API
|
||||
return id
|
||||
}
|
||||
|
||||
// API функции для работы с атрибутами шаблонов
|
||||
const fetchTemplateAttributes = async (
|
||||
templateId: string
|
||||
): Promise<TemplateAttributeDetail[]> => {
|
||||
const response = await fetch(`/api/templates/${templateId}/attributes`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.attributes || []
|
||||
}
|
||||
|
||||
const setTemplateAttributes = async (
|
||||
templateId: string,
|
||||
attributes: Array<{ attribute_id: string; value: string | null }>
|
||||
): Promise<
|
||||
Array<{ template_id: string; attribute_id: string; value: string | null }>
|
||||
> => {
|
||||
const response = await fetch(`/api/templates/${templateId}/attributes`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ attributes }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const CategoryProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const client = useQueryClient()
|
||||
|
||||
const {
|
||||
data: attributes = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<Attribute[], Error>({
|
||||
queryKey: ['attributes'],
|
||||
queryFn: fetchAttributes,
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createAttribute,
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['attributes'] })
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: updateAttribute,
|
||||
onSuccess: updated => {
|
||||
client.setQueryData<Attribute[]>(
|
||||
['attributes'],
|
||||
old => old?.map(attr => (attr.id === updated.id ? updated : attr)) ?? []
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteAttribute,
|
||||
onSuccess: deletedId => {
|
||||
client.setQueryData<Attribute[]>(
|
||||
['attributes'],
|
||||
old => old?.filter(attr => attr.id !== deletedId) ?? []
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
// Утилитарные функции
|
||||
const getRequiredAttributes = () =>
|
||||
attributes.filter(attr => attr.is_required)
|
||||
|
||||
const getOptionalAttributes = () =>
|
||||
attributes.filter(attr => !attr.is_required)
|
||||
|
||||
const getAttributeById = (id: string) =>
|
||||
attributes.find(attr => attr.id === id)
|
||||
|
||||
const categoryValue = useMemo(
|
||||
() => ({
|
||||
attributes,
|
||||
isLoading:
|
||||
isLoading ||
|
||||
createMutation.isPending ||
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending,
|
||||
error:
|
||||
error ||
|
||||
createMutation.error ||
|
||||
updateMutation.error ||
|
||||
deleteMutation.error,
|
||||
refetchAttributes: refetch,
|
||||
addAttribute: createMutation.mutateAsync,
|
||||
updateAttribute: updateMutation.mutateAsync,
|
||||
deleteAttribute: deleteMutation.mutateAsync,
|
||||
getRequiredAttributes,
|
||||
getOptionalAttributes,
|
||||
getAttributeById,
|
||||
}),
|
||||
[
|
||||
attributes,
|
||||
isLoading,
|
||||
error,
|
||||
createMutation,
|
||||
updateMutation,
|
||||
deleteMutation,
|
||||
refetch,
|
||||
]
|
||||
)
|
||||
|
||||
const templateAttributesValue = useMemo(
|
||||
() => ({
|
||||
getTemplateAttributes: fetchTemplateAttributes,
|
||||
setTemplateAttributes: setTemplateAttributes,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<CategoryContext.Provider value={categoryValue}>
|
||||
<TemplateAttributesContext.Provider value={templateAttributesValue}>
|
||||
{children}
|
||||
</TemplateAttributesContext.Provider>
|
||||
</CategoryContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useCategoryContext = (): CategoryContextType => {
|
||||
const context = useContext(CategoryContext)
|
||||
if (!context) {
|
||||
throw new Error('useCategoryContext must be used within a CategoryProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const useTemplateAttributesContext =
|
||||
(): TemplateAttributesContextType => {
|
||||
const context = useContext(TemplateAttributesContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useTemplateAttributesContext must be used within a CategoryProvider'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Хук для работы с атрибутами конкретного шаблона
|
||||
export const useTemplateAttributes = (templateId: string) => {
|
||||
const client = useQueryClient()
|
||||
const { getTemplateAttributes, setTemplateAttributes } =
|
||||
useTemplateAttributesContext()
|
||||
|
||||
const {
|
||||
data: templateAttributes = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<TemplateAttributeDetail[], Error>({
|
||||
queryKey: ['templateAttributes', templateId],
|
||||
queryFn: () => getTemplateAttributes(templateId),
|
||||
enabled: !!templateId,
|
||||
})
|
||||
|
||||
const setAttributesMutation = useMutation({
|
||||
mutationFn: (
|
||||
attributes: Array<{ attribute_id: string; value: string | null }>
|
||||
) => setTemplateAttributes(templateId, attributes),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['templateAttributes', templateId] })
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
templateAttributes,
|
||||
isLoading: isLoading || setAttributesMutation.isPending,
|
||||
error: error || setAttributesMutation.error,
|
||||
refetch,
|
||||
setAttributes: setAttributesMutation.mutateAsync,
|
||||
}
|
||||
}
|
||||
|
||||
// Для обратной совместимости
|
||||
export const useCategoryFields = useCategoryContext
|
||||
|
||||
export default CategoryProvider
|
||||
250
src/entity/template/model/TemplateContext.tsx
Normal file
250
src/entity/template/model/TemplateContext.tsx
Normal 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
|
||||
Reference in New Issue
Block a user