почти рабочий вариант
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
"@types/react-window": "^1.8.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"immer": "^10.1.1",
|
||||
@@ -41,6 +42,7 @@
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-day-picker": "^9.8.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-grid-layout": "^1.5.2",
|
||||
"react-rnd": "^10.5.2",
|
||||
|
||||
@@ -1,782 +0,0 @@
|
||||
import { Badge } from '@/component/ui/badge'
|
||||
import { Button } from '@/component/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { Label } from '@/component/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/component/ui/select'
|
||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Droplets,
|
||||
Edit3,
|
||||
Gauge,
|
||||
Radio,
|
||||
Thermometer,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
interface CalibrationConditions {
|
||||
temperature: string
|
||||
humidity: string
|
||||
pressure: string
|
||||
voltage: string
|
||||
frequency: string
|
||||
lastUpdated: string
|
||||
updatedBy?: string
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
isValid: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface CalibrationCondInfoProps {
|
||||
conditions: CalibrationConditions
|
||||
isOutdated: boolean
|
||||
onUpdate: (conditions: CalibrationConditions) => Promise<boolean>
|
||||
}
|
||||
|
||||
// Валидационные правила
|
||||
const validateField = (field: string, value: string): ValidationResult => {
|
||||
const numValue = parseFloat(value)
|
||||
|
||||
if (!value.trim()) {
|
||||
return { isValid: false, message: 'Поле обязательно' }
|
||||
}
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
return { isValid: false, message: 'Введите число' }
|
||||
}
|
||||
|
||||
switch (field) {
|
||||
case 'temperature':
|
||||
if (numValue < -40 || numValue > 85) {
|
||||
return { isValid: false, message: 'Диапазон: -40...+85°C' }
|
||||
}
|
||||
break
|
||||
case 'humidity':
|
||||
if (numValue < 0 || numValue > 100) {
|
||||
return { isValid: false, message: 'Диапазон: 0...100%' }
|
||||
}
|
||||
break
|
||||
case 'pressure':
|
||||
if (numValue < 80 || numValue > 120) {
|
||||
return { isValid: false, message: 'Диапазон: 80...120 кПа' }
|
||||
}
|
||||
break
|
||||
case 'voltage':
|
||||
if (numValue < 180 || numValue > 250) {
|
||||
return { isValid: false, message: 'Диапазон: 180...250 В' }
|
||||
}
|
||||
break
|
||||
case 'frequency':
|
||||
if (numValue < 45 || numValue > 65) {
|
||||
return { isValid: false, message: 'Диапазон: 45...65 Гц' }
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
const formatLastUpdated = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
const weekdayRu = date.toLocaleString('ru-RU', { weekday: 'long' })
|
||||
const monthShortRu = date
|
||||
.toLocaleString('ru-RU', { month: 'short' })
|
||||
.replace(/\./, '')
|
||||
.replace(/^./, s => s.toUpperCase())
|
||||
const day = date.getDate()
|
||||
const time: string = date.toLocaleString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return `Сегодня в ${time}`
|
||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||
return `Вчера в ${time}`
|
||||
} else {
|
||||
return `${weekdayRu.replace(/^./, s =>
|
||||
s.toUpperCase()
|
||||
)} ${day} ${monthShortRu} ${time}`
|
||||
}
|
||||
}
|
||||
|
||||
function CalibrationCondInfo({
|
||||
conditions,
|
||||
isOutdated,
|
||||
onUpdate,
|
||||
}: CalibrationCondInfoProps) {
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [tempConditions, setTempConditions] =
|
||||
useState<CalibrationConditions>(conditions)
|
||||
const [validationErrors, setValidationErrors] = useState<
|
||||
Record<string, string>
|
||||
>({})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setTempConditions(conditions)
|
||||
}, [conditions])
|
||||
|
||||
const validateForm = () => {
|
||||
const errors: Record<string, string> = {}
|
||||
const fields = [
|
||||
'temperature',
|
||||
'humidity',
|
||||
'pressure',
|
||||
'voltage',
|
||||
'frequency',
|
||||
]
|
||||
|
||||
fields.forEach(field => {
|
||||
const validation = validateField(
|
||||
field,
|
||||
tempConditions[field as keyof CalibrationConditions] as string
|
||||
)
|
||||
if (!validation.isValid && validation.message) {
|
||||
errors[field] = validation.message
|
||||
}
|
||||
})
|
||||
|
||||
setValidationErrors(errors)
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
|
||||
const handleFieldChange = (field: string, value: string) => {
|
||||
setTempConditions(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
// Валидация в реальном времени
|
||||
const validation = validateField(field, value)
|
||||
setValidationErrors(prev => ({
|
||||
...prev,
|
||||
[field]: validation.isValid ? '' : validation.message || '',
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
setIsLoading(true)
|
||||
const newConditions = {
|
||||
...tempConditions,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const success = await onUpdate(newConditions)
|
||||
setIsLoading(false)
|
||||
|
||||
if (success) {
|
||||
setShowModal(false)
|
||||
} else {
|
||||
alert('Не удалось сохранить условия поверки')
|
||||
}
|
||||
}
|
||||
|
||||
const openModal = () => {
|
||||
setTempConditions(conditions)
|
||||
setValidationErrors({})
|
||||
setShowModal(true)
|
||||
// Устанавливаем фокус на первое поле через небольшую задержку
|
||||
setTimeout(() => {
|
||||
const firstInput = document.getElementById(
|
||||
'modal-temperature'
|
||||
) as HTMLInputElement
|
||||
if (firstInput) {
|
||||
firstInput.focus()
|
||||
firstInput.select()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!Object.keys(validationErrors).some(key => validationErrors[key])
|
||||
) {
|
||||
handleSave()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setShowModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fieldConfigs = [
|
||||
{
|
||||
key: 'temperature',
|
||||
label: 'Температура',
|
||||
icon: Thermometer,
|
||||
unit: '°C',
|
||||
placeholder: '20',
|
||||
},
|
||||
{
|
||||
key: 'humidity',
|
||||
label: 'Влажность',
|
||||
icon: Droplets,
|
||||
unit: '%',
|
||||
placeholder: '65',
|
||||
},
|
||||
{
|
||||
key: 'pressure',
|
||||
label: 'Давление',
|
||||
icon: Gauge,
|
||||
unit: 'кПа',
|
||||
placeholder: '101.3',
|
||||
},
|
||||
{
|
||||
key: 'voltage',
|
||||
label: 'Напряжение',
|
||||
icon: Zap,
|
||||
unit: 'В',
|
||||
placeholder: '220',
|
||||
},
|
||||
{
|
||||
key: 'frequency',
|
||||
label: 'Частота',
|
||||
icon: Radio,
|
||||
unit: 'Гц',
|
||||
placeholder: '50',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/50 px-3 py-2">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{fieldConfigs.map(({ key, icon: Icon, unit }) => (
|
||||
<div key={key} className="flex items-center gap-1">
|
||||
<Icon className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">
|
||||
{conditions[key as keyof CalibrationConditions]}
|
||||
{unit}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isOutdated && (
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-yellow-500" />
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={openModal}
|
||||
className={cn(
|
||||
'h-6 w-6 p-0',
|
||||
isOutdated && 'text-yellow-600 hover:text-yellow-700'
|
||||
)}
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно */}
|
||||
{showModal && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/50 p-4"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<Card className="mx-auto my-8 w-full max-w-2xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<CardTitle className="text-lg">Условия поверки</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{fieldConfigs.map(
|
||||
({ key, label, icon: Icon, unit, placeholder }) => {
|
||||
const hasError = validationErrors[key]
|
||||
const isValid =
|
||||
tempConditions[key as keyof CalibrationConditions] &&
|
||||
!hasError
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label
|
||||
htmlFor={`modal-${key}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{label}
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={`modal-${key}`}
|
||||
value={
|
||||
tempConditions[
|
||||
key as keyof CalibrationConditions
|
||||
] as string
|
||||
}
|
||||
onChange={e =>
|
||||
handleFieldChange(key, e.target.value)
|
||||
}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
}}
|
||||
className={`text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none ${
|
||||
hasError
|
||||
? 'border-destructive focus-visible:ring-destructive'
|
||||
: isValid
|
||||
? 'border-green-500 focus-visible:ring-green-500'
|
||||
: ''
|
||||
}`}
|
||||
placeholder={placeholder}
|
||||
type="number"
|
||||
step="0.1"
|
||||
/>
|
||||
<div className="flex min-w-[3rem] items-center gap-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{unit}
|
||||
</span>
|
||||
{isValid && (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
{hasError && (
|
||||
<div className="group relative">
|
||||
<AlertCircle className="h-4 w-4 cursor-help text-destructive" />
|
||||
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 -translate-x-1/2 transform whitespace-nowrap rounded border bg-popover px-2 py-1 text-xs text-popover-foreground opacity-0 shadow-md transition-opacity group-hover:opacity-100">
|
||||
{hasError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t pt-4 text-xs">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-muted-foreground">
|
||||
Обновлено: {formatLastUpdated(conditions.lastUpdated)}
|
||||
</span>
|
||||
</div>
|
||||
{isOutdated && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Устарели
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
disabled={
|
||||
isLoading ||
|
||||
Object.keys(validationErrors).some(
|
||||
key => validationErrors[key]
|
||||
)
|
||||
}
|
||||
>
|
||||
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface CalibrationConditionsConfig {
|
||||
targetCells: any[]
|
||||
defaultConditions?: CalibrationConditions
|
||||
cellMapping?: {
|
||||
[key: string]: string // параметр -> ячейка из targetCells (по индексу)
|
||||
}
|
||||
}
|
||||
|
||||
export const calibrationConditionsDefinition: ElementDefinition<
|
||||
CalibrationConditionsConfig,
|
||||
CalibrationConditions
|
||||
> = {
|
||||
type: 'calibration_conditions',
|
||||
label: 'Условия калибровки',
|
||||
icon: <Thermometer className="h-4 w-4" />,
|
||||
version: 1,
|
||||
defaultConfig: {
|
||||
targetCells: [],
|
||||
cellMapping: {},
|
||||
defaultConditions: {
|
||||
temperature: '20',
|
||||
humidity: '65',
|
||||
pressure: '101.3',
|
||||
voltage: '220',
|
||||
frequency: '50',
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
mapToCells: cfg => {
|
||||
// Используем только ячейки, указанные пользователем в targetCells
|
||||
return cfg.targetCells || []
|
||||
},
|
||||
mapToCellValues: (config, value) => {
|
||||
// Используем targetCells из конфигурации
|
||||
const cells = config.targetCells || []
|
||||
const cellMapping = config.cellMapping || {}
|
||||
|
||||
// Если значение не объект, возвращаем пустые значения
|
||||
if (!value || typeof value !== 'object') {
|
||||
return cells.map(target => ({
|
||||
target,
|
||||
value: '',
|
||||
}))
|
||||
}
|
||||
|
||||
// Создаем обратное сопоставление: индекс ячейки -> параметр
|
||||
const reversedMapping: { [cellIndex: string]: string } = {}
|
||||
Object.entries(cellMapping).forEach(([param, cellIndex]) => {
|
||||
reversedMapping[cellIndex] = param
|
||||
})
|
||||
|
||||
return cells.map((target, idx) => {
|
||||
const paramKey = reversedMapping[idx.toString()]
|
||||
const paramValue = paramKey ? (value as any)[paramKey] : ''
|
||||
|
||||
return {
|
||||
target,
|
||||
value: paramValue || '',
|
||||
}
|
||||
})
|
||||
},
|
||||
Editor: ({ config, onChange }) => {
|
||||
const availableParams = [
|
||||
{ key: 'temperature', label: 'Температура' },
|
||||
{ key: 'humidity', label: 'Влажность' },
|
||||
{ key: 'pressure', label: 'Давление' },
|
||||
{ key: 'voltage', label: 'Напряжение' },
|
||||
{ key: 'frequency', label: 'Частота' },
|
||||
{ key: 'lastUpdated', label: 'Дата обновления' },
|
||||
]
|
||||
|
||||
const targetCells = config.targetCells || []
|
||||
const cellMapping = config.cellMapping || {}
|
||||
|
||||
// Функция для создания автоматического сопоставления по порядку
|
||||
const createAutoMapping = () => {
|
||||
const newMapping: { [key: string]: string } = {}
|
||||
const paramKeys = availableParams.map(p => p.key)
|
||||
|
||||
// Сопоставляем первые N параметров с первыми N ячейками
|
||||
targetCells.forEach((_, index) => {
|
||||
if (index < paramKeys.length) {
|
||||
newMapping[paramKeys[index]] = index.toString()
|
||||
}
|
||||
})
|
||||
|
||||
onChange({
|
||||
...config,
|
||||
cellMapping: newMapping,
|
||||
})
|
||||
}
|
||||
|
||||
const updateCellMapping = (param: string, cellIndex: string) => {
|
||||
const newMapping = { ...cellMapping }
|
||||
if (cellIndex === '' || cellIndex === 'none') {
|
||||
delete newMapping[param]
|
||||
} else {
|
||||
newMapping[param] = cellIndex
|
||||
}
|
||||
onChange({
|
||||
...config,
|
||||
cellMapping: newMapping,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Элемент "Условия калибровки" позволяет вводить и отслеживать
|
||||
параметры окружающей среды при проведении измерений. Поддерживает
|
||||
валидацию значений и отслеживание времени обновления.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Сопоставление параметров с ячейками */}
|
||||
{targetCells.length > 0 && (
|
||||
<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={createAutoMapping}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Автозаполнение
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3 rounded-lg border border-gray-200 p-4">
|
||||
{availableParams.map(param => (
|
||||
<div key={param.key} className="flex items-center gap-3">
|
||||
<div className="min-w-[120px]">
|
||||
<span className="text-sm text-gray-700">{param.label}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={cellMapping[param.key] || 'none'}
|
||||
onValueChange={value =>
|
||||
updateCellMapping(param.key, value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Не указано" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Не указано</SelectItem>
|
||||
{targetCells.map((cell, index) => (
|
||||
<SelectItem key={index} value={index.toString()}>
|
||||
{cell.sheet}!{cell.cell}{' '}
|
||||
{cell.displayName ? `(${cell.displayName})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Настройте какой параметр условий калибровки будет записан в какую
|
||||
ячейку. Используйте кнопку "Автозаполнение" для автоматического
|
||||
сопоставления по порядку.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Параметры по умолчанию</label>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-lg border border-gray-200 p-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-gray-600">
|
||||
Температура (°C)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.defaultConditions?.temperature || '20'}
|
||||
onChange={e =>
|
||||
onChange({
|
||||
...config,
|
||||
defaultConditions: {
|
||||
...config.defaultConditions,
|
||||
temperature: e.target.value,
|
||||
humidity: config.defaultConditions?.humidity || '65',
|
||||
pressure: config.defaultConditions?.pressure || '101.3',
|
||||
voltage: config.defaultConditions?.voltage || '220',
|
||||
frequency: config.defaultConditions?.frequency || '50',
|
||||
lastUpdated:
|
||||
config.defaultConditions?.lastUpdated ||
|
||||
new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="20"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-gray-600">
|
||||
Влажность (%)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.defaultConditions?.humidity || '65'}
|
||||
onChange={e =>
|
||||
onChange({
|
||||
...config,
|
||||
defaultConditions: {
|
||||
...config.defaultConditions,
|
||||
temperature:
|
||||
config.defaultConditions?.temperature || '20',
|
||||
humidity: e.target.value,
|
||||
pressure: config.defaultConditions?.pressure || '101.3',
|
||||
voltage: config.defaultConditions?.voltage || '220',
|
||||
frequency: config.defaultConditions?.frequency || '50',
|
||||
lastUpdated:
|
||||
config.defaultConditions?.lastUpdated ||
|
||||
new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="65"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-gray-600">
|
||||
Давление (кПа)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.defaultConditions?.pressure || '101.3'}
|
||||
onChange={e =>
|
||||
onChange({
|
||||
...config,
|
||||
defaultConditions: {
|
||||
...config.defaultConditions,
|
||||
temperature:
|
||||
config.defaultConditions?.temperature || '20',
|
||||
humidity: config.defaultConditions?.humidity || '65',
|
||||
pressure: e.target.value,
|
||||
voltage: config.defaultConditions?.voltage || '220',
|
||||
frequency: config.defaultConditions?.frequency || '50',
|
||||
lastUpdated:
|
||||
config.defaultConditions?.lastUpdated ||
|
||||
new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="101.3"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-gray-600">
|
||||
Напряжение (В)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.defaultConditions?.voltage || '220'}
|
||||
onChange={e =>
|
||||
onChange({
|
||||
...config,
|
||||
defaultConditions: {
|
||||
...config.defaultConditions,
|
||||
temperature:
|
||||
config.defaultConditions?.temperature || '20',
|
||||
humidity: config.defaultConditions?.humidity || '65',
|
||||
pressure: config.defaultConditions?.pressure || '101.3',
|
||||
voltage: e.target.value,
|
||||
frequency: config.defaultConditions?.frequency || '50',
|
||||
lastUpdated:
|
||||
config.defaultConditions?.lastUpdated ||
|
||||
new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="220"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-gray-600">
|
||||
Частота (Гц)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.defaultConditions?.frequency || '50'}
|
||||
onChange={e =>
|
||||
onChange({
|
||||
...config,
|
||||
defaultConditions: {
|
||||
...config.defaultConditions,
|
||||
temperature:
|
||||
config.defaultConditions?.temperature || '20',
|
||||
humidity: config.defaultConditions?.humidity || '65',
|
||||
pressure: config.defaultConditions?.pressure || '101.3',
|
||||
voltage: config.defaultConditions?.voltage || '220',
|
||||
frequency: e.target.value,
|
||||
lastUpdated:
|
||||
config.defaultConditions?.lastUpdated ||
|
||||
new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
Preview: ({ config }) => (
|
||||
<div className="space-y-3">
|
||||
<CalibrationCondInfo
|
||||
conditions={
|
||||
config.defaultConditions || {
|
||||
temperature: '20',
|
||||
humidity: '65',
|
||||
pressure: '101.3',
|
||||
voltage: '220',
|
||||
frequency: '50',
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
isOutdated={false}
|
||||
onUpdate={async () => true}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Render: ({ config, value, onChange }) => {
|
||||
// Если значение не установлено, используем defaultConditions из конфигурации
|
||||
const conditions = value ||
|
||||
config.defaultConditions || {
|
||||
temperature: '20',
|
||||
humidity: '65',
|
||||
pressure: '101.3',
|
||||
voltage: '220',
|
||||
frequency: '50',
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Проверяем, устарели ли условия (больше 24 часов)
|
||||
const lastUpdated = new Date(conditions.lastUpdated)
|
||||
const now = new Date()
|
||||
const isOutdated =
|
||||
now.getTime() - lastUpdated.getTime() > 24 * 60 * 60 * 1000
|
||||
|
||||
return (
|
||||
<CalibrationCondInfo
|
||||
conditions={conditions}
|
||||
isOutdated={isOutdated}
|
||||
onUpdate={async newConditions => {
|
||||
onChange?.(newConditions)
|
||||
return true
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export { textDefinition } from '@/entitiy/element/model/implementations/TextElement'
|
||||
export { buttonGroupDefinition } from '../../entitiy/element/model/implementations/ButtonGroup'
|
||||
export { dateDefinition } from '../../entitiy/element/model/implementations/DateElement'
|
||||
export { calibrationConditionsDefinition } from './CalibrationConditionsElement'
|
||||
export { numberDefinition } from './NumberElement'
|
||||
export { selectDefinition } from './SelectElement'
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
import { Button } from '@/component/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { Separator } from '@/component/ui/separator'
|
||||
import { useToast } from '@/lib/hooks/useToast'
|
||||
import { Check, FileText, Gauge, Search, Settings, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
interface MeasurementStandard {
|
||||
id: string
|
||||
name: string
|
||||
shortName: string
|
||||
type: string
|
||||
registryNumber: string
|
||||
range: string
|
||||
accuracy: string
|
||||
certificateNumber: string
|
||||
validUntil: string
|
||||
}
|
||||
|
||||
interface StandardsConfig {
|
||||
registryId: string
|
||||
selectedStandards: string[]
|
||||
lastUpdated: string
|
||||
}
|
||||
|
||||
interface StandardsDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
standards: MeasurementStandard[]
|
||||
config: StandardsConfig
|
||||
onSave: (config: StandardsConfig) => void
|
||||
}
|
||||
|
||||
export function StandardsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
standards,
|
||||
config,
|
||||
onSave,
|
||||
}: StandardsDialogProps) {
|
||||
const [selectedStandards, setSelectedStandards] = useState<string[]>(
|
||||
config.selectedStandards
|
||||
)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const { error } = useToast()
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Автофокус на поле поиска при открытии
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
// Небольшая задержка для корректной работы с анимацией диалога
|
||||
const timer = setTimeout(() => {
|
||||
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
|
||||
}
|
||||
|
||||
// Обработка Backspace для удаления последнего символа
|
||||
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])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const filteredStandards = useMemo(() => {
|
||||
if (!searchTerm) return standards
|
||||
|
||||
const searchLower = searchTerm.toLowerCase()
|
||||
return standards.filter(
|
||||
standard =>
|
||||
standard.name.toLowerCase().includes(searchLower) ||
|
||||
standard.shortName.toLowerCase().includes(searchLower) ||
|
||||
standard.type.toLowerCase().includes(searchLower) ||
|
||||
standard.registryNumber.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}, [searchTerm, standards])
|
||||
|
||||
const selectedStandardsData = standards.filter(standard =>
|
||||
selectedStandards.includes(standard.id)
|
||||
)
|
||||
|
||||
const unselectedStandards = filteredStandards.filter(
|
||||
standard => !selectedStandards.includes(standard.id)
|
||||
)
|
||||
|
||||
const isMaxReached = selectedStandards.length >= 7
|
||||
|
||||
const toggleStandard = (standardId: string) => {
|
||||
setSelectedStandards(prev => {
|
||||
if (prev.includes(standardId)) {
|
||||
return prev.filter(id => id !== standardId)
|
||||
} else if (prev.length < 7) {
|
||||
return [...prev, standardId]
|
||||
} else {
|
||||
error('Можно выбрать максимум 7 эталонов', {
|
||||
title: 'Достигнут лимит',
|
||||
})
|
||||
return prev
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const removeStandard = (standardId: string) => {
|
||||
setSelectedStandards(prev => prev.filter(id => id !== standardId))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
...config,
|
||||
selectedStandards,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedStandards(config.selectedStandards) // Сбрасываем к исходному значению
|
||||
setSearchTerm('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const isExpiringSoon = (validUntil: string) => {
|
||||
const expiryDate = new Date(validUntil)
|
||||
const now = new Date()
|
||||
const monthsUntilExpiry =
|
||||
(expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30)
|
||||
return monthsUntilExpiry <= 3
|
||||
}
|
||||
|
||||
const renderStandardCard = (
|
||||
standard: MeasurementStandard,
|
||||
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.shortName}
|
||||
</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.registryNumber}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Gauge className="h-3 w-3" />
|
||||
<span>{standard.range}</span>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<Card className="flex max-h-[95vh] w-full max-w-6xl flex-col overflow-hidden">
|
||||
<CardHeader className="shrink-0 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle 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}/7)
|
||||
</span>
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-1 flex-col space-y-4 overflow-hidden">
|
||||
{/* Поиск */}
|
||||
<div className="relative shrink-0">
|
||||
<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>
|
||||
|
||||
{/* Контент с прокруткой */}
|
||||
<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})
|
||||
</h4>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto">
|
||||
{selectedStandardsData.map(standard =>
|
||||
renderStandardCard(standard, true, true)
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="shrink-0" />
|
||||
|
||||
{/* Зафиксированные кнопки */}
|
||||
<div className="shrink-0">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleClose} size="sm">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleSave} size="sm">
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Badge } from '@/component/ui/badge'
|
||||
import { Button } from '@/component/ui/button'
|
||||
import { Label } from '@/component/ui/label'
|
||||
import { FileText, Settings } from 'lucide-react'
|
||||
import { mockStandards } from './mockStandards'
|
||||
|
||||
interface StandardsConfig {
|
||||
sheet: string
|
||||
startCell: string
|
||||
maxItems: number
|
||||
targetCells: any[]
|
||||
}
|
||||
|
||||
interface StandardsPreviewProps {
|
||||
config: StandardsConfig
|
||||
}
|
||||
|
||||
export const StandardsPreview: React.FC<StandardsPreviewProps> = ({
|
||||
config,
|
||||
}) => {
|
||||
// Берем первые config.maxItems эталонов для предпросмотра
|
||||
const previewStandards = mockStandards.slice(0, 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">
|
||||
{previewStandards.map((standard, index) => (
|
||||
<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.registryNumber}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{standard.range}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { Badge } from '@/component/ui/badge'
|
||||
import { Button } from '@/component/ui/button'
|
||||
import { Label } from '@/component/ui/label'
|
||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||
import { CellTarget } from '@/type/template'
|
||||
import { FileText, Settings, Weight } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useStandards } from './hooks'
|
||||
import { getStandardById } from './mockStandards'
|
||||
import { StandardsEditor } from './StandardsEditor'
|
||||
import { StandardsPreview } from './StandardsPreview'
|
||||
import { StandardsSelector } from './StandardsSelector'
|
||||
|
||||
interface StandardsConfig {
|
||||
sheet: string // Лист Excel
|
||||
startCell: string // Первая ячейка (например "A10")
|
||||
maxItems: number // Обычно 7
|
||||
targetCells: CellTarget[]
|
||||
selectedStandards?: string[] // Выбранные эталоны (ID)
|
||||
}
|
||||
|
||||
export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
|
||||
{
|
||||
type: 'standards',
|
||||
label: 'Измерительные эталоны',
|
||||
icon: <Weight className="h-4 w-4" />,
|
||||
version: 1,
|
||||
defaultConfig: {
|
||||
sheet: 'L',
|
||||
startCell: 'A1',
|
||||
maxItems: 7,
|
||||
targetCells: [],
|
||||
selectedStandards: [], // По умолчанию пустой массив
|
||||
},
|
||||
Editor: StandardsEditor,
|
||||
Preview: StandardsPreview, // Использует моки для демонстрации
|
||||
Render: ({ config, value, onChange }) => {
|
||||
const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false)
|
||||
const [selectedStandardIds, setSelectedStandardIds] = useState<string[]>(
|
||||
// Инициализируем из config.selectedStandards или value
|
||||
config.selectedStandards || (Array.isArray(value) ? value : [])
|
||||
)
|
||||
|
||||
// Получаем реальные данные эталонов
|
||||
const { data: standards = [] } = useStandards()
|
||||
|
||||
// Синхронизируем selectedStandardIds с config и value
|
||||
useEffect(() => {
|
||||
const standards =
|
||||
config.selectedStandards || (Array.isArray(value) ? value : [])
|
||||
setSelectedStandardIds(standards)
|
||||
}, [config.selectedStandards, value])
|
||||
|
||||
// Получаем данные выбранных эталонов из реального API
|
||||
const selectedStandardsData = selectedStandardIds
|
||||
.map(id => standards.find(s => s.id === id))
|
||||
.filter(Boolean)
|
||||
|
||||
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"
|
||||
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)
|
||||
// Сохраняем ID эталонов в value для записи в Excel
|
||||
onChange?.(standardIds)
|
||||
}}
|
||||
maxItems={config.maxItems}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
mapToCells: cfg => {
|
||||
// Используем только ячейки, указанные пользователем в targetCells
|
||||
return cfg.targetCells || []
|
||||
},
|
||||
mapToCellValues: (cfg, value) => {
|
||||
// Используем targetCells из конфигурации
|
||||
const cells = cfg.targetCells || []
|
||||
|
||||
// Конвертируем ID эталонов в их названия для записи в Excel
|
||||
// Здесь используем моки для быстрого доступа к данным при записи в Excel
|
||||
const standardIds = Array.isArray(value) ? value : []
|
||||
const standardNames = standardIds.map(
|
||||
id => getStandardById(id)?.name || ''
|
||||
)
|
||||
|
||||
return cells.map((target, idx) => ({
|
||||
target,
|
||||
value: standardNames[idx] || '', // Берем значение по индексу или пустую строку
|
||||
}))
|
||||
},
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
export interface MeasurementStandard {
|
||||
id: string
|
||||
name: string
|
||||
shortName: string
|
||||
type: string
|
||||
registryNumber: string
|
||||
range: string
|
||||
accuracy: string
|
||||
certificateNumber: string
|
||||
validUntil: string
|
||||
}
|
||||
|
||||
// Общие моковые данные для эталонов
|
||||
export const mockStandards: MeasurementStandard[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Эталон массы 1 кг',
|
||||
shortName: 'ЭМ-1кг',
|
||||
type: 'Масса',
|
||||
registryNumber: 'ГРСИ 12345-01',
|
||||
range: '0.5-2 кг',
|
||||
accuracy: '±0.001 г',
|
||||
certificateNumber: 'СИ-2024-001',
|
||||
validUntil: '2025-12-31',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Эталон длины 1 м',
|
||||
shortName: 'ЭД-1м',
|
||||
type: 'Длина',
|
||||
registryNumber: 'ГРСИ 12345-02',
|
||||
range: '0.5-2 м',
|
||||
accuracy: '±0.001 мм',
|
||||
certificateNumber: 'СИ-2024-002',
|
||||
validUntil: '2025-06-30',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Эталон температуры 20°C',
|
||||
shortName: 'ЭТ-20°C',
|
||||
type: 'Температура',
|
||||
registryNumber: 'ГРСИ 12345-03',
|
||||
range: '15-25°C',
|
||||
accuracy: '±0.01°C',
|
||||
certificateNumber: 'СИ-2024-003',
|
||||
validUntil: '2025-03-15',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Эталон давления 1 Па',
|
||||
shortName: 'ЭД-1Па',
|
||||
type: 'Давление',
|
||||
registryNumber: 'ГРСИ 12345-04',
|
||||
range: '0.5-2 Па',
|
||||
accuracy: '±0.001 Па',
|
||||
certificateNumber: 'СИ-2024-004',
|
||||
validUntil: '2025-09-20',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Эталон времени 1 с',
|
||||
shortName: 'ЭВ-1с',
|
||||
type: 'Время',
|
||||
registryNumber: 'ГРСИ 12345-05',
|
||||
range: '0.5-2 с',
|
||||
accuracy: '±0.000001 с',
|
||||
certificateNumber: 'СИ-2024-005',
|
||||
validUntil: '2025-12-01',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Эталон электрического напряжения 10 В',
|
||||
shortName: 'ЭН-10В',
|
||||
type: 'Электричество',
|
||||
registryNumber: 'ГРСИ 12345-06',
|
||||
range: '1-100 В',
|
||||
accuracy: '±0.01 В',
|
||||
certificateNumber: 'СИ-2024-006',
|
||||
validUntil: '2025-08-15',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
name: 'Эталон силы тока 1 А',
|
||||
shortName: 'ЭТ-1А',
|
||||
type: 'Электричество',
|
||||
registryNumber: 'ГРСИ 12345-07',
|
||||
range: '0.1-10 А',
|
||||
accuracy: '±0.001 А',
|
||||
certificateNumber: 'СИ-2024-007',
|
||||
validUntil: '2025-11-30',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
name: 'Эталон частоты 1 кГц',
|
||||
shortName: 'ЭЧ-1кГц',
|
||||
type: 'Частота',
|
||||
registryNumber: 'ГРСИ 12345-08',
|
||||
range: '10 Гц - 10 кГц',
|
||||
accuracy: '±0.0001 Гц',
|
||||
certificateNumber: 'СИ-2024-008',
|
||||
validUntil: '2025-07-20',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
name: 'Эталон влажности 50% RH',
|
||||
shortName: 'ЭВ-50%',
|
||||
type: 'Влажность',
|
||||
registryNumber: 'ГРСИ 12345-09',
|
||||
range: '10-90% RH',
|
||||
accuracy: '±0.5% RH',
|
||||
certificateNumber: 'СИ-2024-009',
|
||||
validUntil: '2025-04-10',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
name: 'Эталон освещенности 1000 лк',
|
||||
shortName: 'ЭО-1000лк',
|
||||
type: 'Освещенность',
|
||||
registryNumber: 'ГРСИ 12345-10',
|
||||
range: '1-10000 лк',
|
||||
accuracy: '±1 лк',
|
||||
certificateNumber: 'СИ-2024-010',
|
||||
validUntil: '2025-10-05',
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
name: 'Эталон скорости 10 м/с',
|
||||
shortName: 'ЭС-10м/с',
|
||||
type: 'Скорость',
|
||||
registryNumber: 'ГРСИ 12345-11',
|
||||
range: '0.1-50 м/с',
|
||||
accuracy: '±0.01 м/с',
|
||||
certificateNumber: 'СИ-2024-011',
|
||||
validUntil: '2025-05-25',
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
name: 'Эталон ускорения 9.8 м/с²',
|
||||
shortName: 'ЭУ-9.8м/с²',
|
||||
type: 'Ускорение',
|
||||
registryNumber: 'ГРСИ 12345-12',
|
||||
range: '0.1-20 м/с²',
|
||||
accuracy: '±0.001 м/с²',
|
||||
certificateNumber: 'СИ-2024-012',
|
||||
validUntil: '2025-12-15',
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
name: 'Эталон магнитной индукции 1 Тл',
|
||||
shortName: 'ЭМИ-1Тл',
|
||||
type: 'Магнетизм',
|
||||
registryNumber: 'ГРСИ 12345-13',
|
||||
range: '0.01-10 Тл',
|
||||
accuracy: '±0.0001 Тл',
|
||||
certificateNumber: 'СИ-2024-013',
|
||||
validUntil: '2025-09-08',
|
||||
},
|
||||
{
|
||||
id: '14',
|
||||
name: 'Эталон акустического давления 1 Па',
|
||||
shortName: 'ЭАД-1Па',
|
||||
type: 'Акустика',
|
||||
registryNumber: 'ГРСИ 12345-14',
|
||||
range: '0.02-200 Па',
|
||||
accuracy: '±0.1 дБ',
|
||||
certificateNumber: 'СИ-2024-014',
|
||||
validUntil: '2025-06-12',
|
||||
},
|
||||
{
|
||||
id: '15',
|
||||
name: 'Эталон угла поворота 1°',
|
||||
shortName: 'ЭУП-1°',
|
||||
type: 'Угол',
|
||||
registryNumber: 'ГРСИ 12345-15',
|
||||
range: '0-360°',
|
||||
accuracy: '±0.001°',
|
||||
certificateNumber: 'СИ-2024-015',
|
||||
validUntil: '2025-08-30',
|
||||
},
|
||||
]
|
||||
|
||||
// Утилиты для работы с эталонами
|
||||
export const getStandardById = (
|
||||
id: string
|
||||
): MeasurementStandard | undefined => {
|
||||
return mockStandards.find(standard => standard.id === id)
|
||||
}
|
||||
|
||||
export const getStandardByName = (
|
||||
name: string
|
||||
): MeasurementStandard | undefined => {
|
||||
return mockStandards.find(standard => standard.name === name)
|
||||
}
|
||||
|
||||
// Функция для получения полезного badge по типу эталона
|
||||
export const getStandardTypeBadge = (type: string) => {
|
||||
switch (type) {
|
||||
case 'Масса':
|
||||
return 'МАССА'
|
||||
case 'Длина':
|
||||
return 'ДЛИНА'
|
||||
case 'Температура':
|
||||
return 'ТЕМП'
|
||||
case 'Давление':
|
||||
return 'ДАВЛ'
|
||||
case 'Время':
|
||||
return 'ВРЕМЯ'
|
||||
case 'Электричество':
|
||||
return 'ЭЛЕКТ'
|
||||
case 'Частота':
|
||||
return 'ЧАСТ'
|
||||
case 'Влажность':
|
||||
return 'ВЛАЖ'
|
||||
case 'Освещенность':
|
||||
return 'СВЕТ'
|
||||
case 'Скорость':
|
||||
return 'СКОР'
|
||||
case 'Ускорение':
|
||||
return 'УСКОР'
|
||||
case 'Магнетизм':
|
||||
return 'МАГН'
|
||||
case 'Акустика':
|
||||
return 'ЗВУК'
|
||||
case 'Угол':
|
||||
return 'УГОЛ'
|
||||
default:
|
||||
return 'ЭТЛ'
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ const CellTargetEditor: React.FC<{
|
||||
|
||||
{/* Поле ввода ячейки */}
|
||||
<Input
|
||||
placeholder="A1"
|
||||
placeholder="A12"
|
||||
value={target.cell}
|
||||
onChange={e => handleCellInputChange(index, e.target.value)}
|
||||
className="h-6 w-16 px-2 text-xs"
|
||||
@@ -231,36 +231,6 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
? getElementDefinition(formData.type)
|
||||
: null
|
||||
|
||||
const handleAddOption = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
options: [...(prev.options || []), { value: '', label: '' }],
|
||||
}))
|
||||
}
|
||||
|
||||
const handleUpdateOption = (
|
||||
index: number,
|
||||
field: 'value' | 'label',
|
||||
value: string
|
||||
) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
options: prev.options?.map((option, i) =>
|
||||
i === index ? { ...option, [field]: value } : option
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleRemoveOption = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
options: prev.options?.filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
const needsOptions =
|
||||
formData.type === 'select' || formData.type === 'button_group'
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Настройки элемента */}
|
||||
@@ -394,69 +364,6 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Варианты для select/radio/button_group */}
|
||||
{needsOptions && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-orange-600" />
|
||||
<CardTitle className="text-sm">Варианты выбора</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddOption}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-32 space-y-2 overflow-y-auto">
|
||||
{formData.options?.map((option, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Значение"
|
||||
value={option.value}
|
||||
onChange={e =>
|
||||
handleUpdateOption(index, 'value', e.target.value)
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Подпись"
|
||||
value={option.label}
|
||||
onChange={e =>
|
||||
handleUpdateOption(index, 'label', e.target.value)
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveOption(index)}
|
||||
className="h-8 w-8 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{(!formData.options || formData.options.length === 0) && (
|
||||
<div className="py-4 text-center">
|
||||
<Tag className="mx-auto mb-2 h-6 w-6 text-gray-300" />
|
||||
<p className="text-xs text-gray-500">
|
||||
Добавьте варианты выбора
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Предпросмотр */}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Badge } from '@/component/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
|
||||
import { Label } from '@/component/ui/label'
|
||||
import {
|
||||
Select,
|
||||
@@ -8,13 +7,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/component/ui/select'
|
||||
import { Building2, Target } from 'lucide-react'
|
||||
import { useLaboratories } from './hooks'
|
||||
import { mockApiHelpers } from './mockData'
|
||||
import { ConditionCellMapping, VerificationConditionsConfig } from './types'
|
||||
import { CellTarget } from '@/type/template'
|
||||
import { Building2 } from 'lucide-react'
|
||||
import { useLaboratories, useLaboratoryConditions } from './hooks'
|
||||
import { ConditionMapping, VerificationConditionsConfig } from './types'
|
||||
|
||||
interface VerificationConditionsEditorProps {
|
||||
config: VerificationConditionsConfig
|
||||
config: VerificationConditionsConfig & { targetCells?: CellTarget[] }
|
||||
onChange: (config: VerificationConditionsConfig) => void
|
||||
}
|
||||
|
||||
@@ -23,204 +22,198 @@ export function VerificationConditionsEditor({
|
||||
onChange,
|
||||
}: VerificationConditionsEditorProps) {
|
||||
const { data: laboratories = [], isLoading } = useLaboratories()
|
||||
const { data: labData } = useLaboratoryConditions(config.selectedLaboratoryId)
|
||||
|
||||
const updateConfig = (updates: Partial<VerificationConditionsConfig>) => {
|
||||
onChange({ ...config, ...updates })
|
||||
}
|
||||
|
||||
// Получаем выбранную лабораторию
|
||||
const selectedLab = laboratories.find(
|
||||
lab => lab.id === config.selectedLaboratoryId
|
||||
)
|
||||
// Получить доступные целевые ячейки из конфигурации
|
||||
const getAvailableTargetCells = () => {
|
||||
return config.targetCells || []
|
||||
}
|
||||
|
||||
// Получаем все доступные условия (либо все из БД, либо условия конкретной лаборатории)
|
||||
const availableConditions = config.selectedLaboratoryId
|
||||
? mockApiHelpers.getLaboratoryConditions(config.selectedLaboratoryId)
|
||||
: mockApiHelpers.getAllConditions()
|
||||
// Обновить маппинг для конкретного условия
|
||||
const updateConditionMapping = (
|
||||
conditionId: string,
|
||||
conditionName: string,
|
||||
targetCellKey?: string // формат: "sheet:cell", например "L:A1"
|
||||
) => {
|
||||
const currentMappings = config.conditionMappings || []
|
||||
let updatedMappings = [...currentMappings]
|
||||
|
||||
// Обновление привязки условия к ячейке
|
||||
const updateConditionMapping = (cellIndex: number, conditionKey: string) => {
|
||||
const conditionName =
|
||||
availableConditions.find(c => c.key === conditionKey)?.name ||
|
||||
conditionKey
|
||||
// Удаляем существующий маппинг для этого условия
|
||||
updatedMappings = updatedMappings.filter(
|
||||
mapping => mapping.conditionKey !== conditionId
|
||||
)
|
||||
|
||||
const newMappings = [...(config.conditionMappings || [])]
|
||||
const existingIndex = newMappings.findIndex(m => m.cellIndex === cellIndex)
|
||||
// Если выбрана ячейка, добавляем новый маппинг
|
||||
if (targetCellKey) {
|
||||
const [sheet, cell] = targetCellKey.split(':')
|
||||
const targetCells = getAvailableTargetCells()
|
||||
const targetIndex = targetCells.findIndex(
|
||||
tc => tc.sheet === sheet && tc.cell === cell
|
||||
)
|
||||
|
||||
if (conditionKey === '__none__') {
|
||||
// Убираем привязку
|
||||
if (existingIndex >= 0) {
|
||||
newMappings.splice(existingIndex, 1)
|
||||
}
|
||||
} else {
|
||||
// Добавляем или обновляем привязку
|
||||
const mapping: ConditionCellMapping = {
|
||||
cellIndex,
|
||||
conditionKey,
|
||||
conditionName,
|
||||
}
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
newMappings[existingIndex] = mapping
|
||||
} else {
|
||||
newMappings.push(mapping)
|
||||
if (targetIndex !== -1) {
|
||||
const newMapping: ConditionMapping = {
|
||||
conditionKey: conditionId,
|
||||
conditionName: conditionName,
|
||||
cellIndex: targetIndex, // используем индекс в массиве targetCells
|
||||
}
|
||||
updatedMappings.push(newMapping)
|
||||
}
|
||||
}
|
||||
|
||||
updateConfig({ conditionMappings: newMappings })
|
||||
updateConfig({ conditionMappings: updatedMappings })
|
||||
}
|
||||
|
||||
// Получить выбранное условие для ячейки
|
||||
const getSelectedCondition = (cellIndex: number): string => {
|
||||
const mapping = config.conditionMappings?.find(
|
||||
m => m.cellIndex === cellIndex
|
||||
// Получить выбранную ячейку для условия
|
||||
const getSelectedTargetCell = (conditionId: string): string | undefined => {
|
||||
const mapping = (config.conditionMappings || []).find(
|
||||
mapping => mapping.conditionKey === conditionId
|
||||
)
|
||||
return mapping?.conditionKey || '__none__'
|
||||
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()
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Выбор лаборатории */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
Лаборатория
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div>
|
||||
<Label htmlFor="laboratory">Выберите лабораторию</Label>
|
||||
<Select
|
||||
value={config.selectedLaboratoryId?.toString() || '__none__'}
|
||||
onValueChange={value => {
|
||||
const laboratoryId =
|
||||
value === '__none__' ? undefined : parseInt(value)
|
||||
updateConfig({
|
||||
selectedLaboratoryId: laboratoryId,
|
||||
conditionMappings: [], // Сбрасываем привязки при смене лаборатории
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите лабораторию" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Все условия из БД</SelectItem>
|
||||
{laboratories.map(lab => (
|
||||
<SelectItem key={lab.id} value={lab.id.toString()}>
|
||||
{lab.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{config.selectedLaboratoryId
|
||||
? 'Доступны только условия выбранной лаборатории'
|
||||
: 'Доступны все условия из базы данных'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Компактный выбор лаборатории */}
|
||||
<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>
|
||||
|
||||
{/* Привязка условий к ячейкам */}
|
||||
{config.targetCells.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4" />
|
||||
Привязка условий к ячейкам
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-800 dark:bg-blue-950 dark:text-blue-200">
|
||||
<p className="font-medium">Настройка записи данных</p>
|
||||
<p className="mt-1">
|
||||
Выберите какое условие будет записываться в каждую целевую
|
||||
ячейку.
|
||||
</p>
|
||||
</div>
|
||||
{/* Маппинг условий */}
|
||||
{labData && targetCells.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium text-gray-700">
|
||||
Маппинг условий поверки
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
{labData.conditionTypes.map(conditionType => {
|
||||
const selectedCell = getSelectedTargetCell(conditionType.id)
|
||||
const occupiedCells = getOccupiedTargetCells(conditionType.id)
|
||||
|
||||
<div className="space-y-3">
|
||||
{config.targetCells.map((cell, index) => (
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 rounded-md border p-3"
|
||||
key={conditionType.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-gray-200 bg-gray-50/50 px-3 py-2"
|
||||
>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{cell.sheet}!{cell.cell}
|
||||
</Badge>
|
||||
{/* Информация об условии */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{conditionType.name}
|
||||
</span>
|
||||
<Badge variant="secondary" className="px-2 py-0.5 text-xs">
|
||||
{conditionType.unit}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Ячейка {index + 1} - выберите условие:
|
||||
</Label>
|
||||
<Select
|
||||
value={getSelectedCondition(index)}
|
||||
onValueChange={value =>
|
||||
updateConditionMapping(index, value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Выберите условие" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Не выбрано</SelectItem>
|
||||
{availableConditions.map(condition => (
|
||||
<SelectItem key={condition.key} value={condition.key}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{condition.name}</span>
|
||||
{condition.unit && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{condition.unit}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Компактный селектор ячейки */}
|
||||
<Select
|
||||
value={selectedCell || 'UNSELECTED'}
|
||||
onValueChange={value =>
|
||||
updateConditionMapping(
|
||||
conditionType.id,
|
||||
conditionType.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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Подсказка с доступными условиями */}
|
||||
<div className="rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
|
||||
<p className="font-medium">
|
||||
Доступные условия{' '}
|
||||
{selectedLab ? `для ${selectedLab.name}` : 'из базы данных'}:
|
||||
</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{availableConditions.map(condition => (
|
||||
<li key={condition.key}>
|
||||
• <strong>{condition.name}</strong>
|
||||
{condition.unit && ` (${condition.unit})`}
|
||||
{'isRequired' in condition &&
|
||||
condition.isRequired &&
|
||||
' - обязательное'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{labData.conditionTypes.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Подсказка если нет целевых ячеек */}
|
||||
{config.targetCells.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<Target className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p className="font-medium">Целевые ячейки не настроены</p>
|
||||
<p className="mt-1">
|
||||
Сначала настройте целевые ячейки в основном редакторе элементов,
|
||||
затем вернитесь сюда для привязки условий.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Предупреждение если нет целевых ячеек */}
|
||||
{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>
|
||||
)
|
||||
|
||||
@@ -1,129 +1,182 @@
|
||||
import { Badge } from '@/component/ui/badge'
|
||||
import { Button } from '@/component/ui/button'
|
||||
import { Calendar } from '@/component/ui/calendar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
|
||||
import { Building2, Calendar, Droplets, Gauge, Thermometer } from 'lucide-react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/component/ui/popover'
|
||||
import {
|
||||
AlertCircle,
|
||||
Building2,
|
||||
Calendar as CalendarIcon,
|
||||
CheckCircle,
|
||||
Plus,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
useDailyConditions,
|
||||
useLaboratories,
|
||||
useLaboratoryConditions,
|
||||
} from './hooks'
|
||||
import { VerificationConditionsConfig } from './types'
|
||||
|
||||
interface VerificationConditionsPreviewProps {
|
||||
config: VerificationConditionsConfig
|
||||
}
|
||||
|
||||
// Моковые данные для предварительного просмотра
|
||||
const mockData = {
|
||||
laboratory: {
|
||||
id: 1,
|
||||
name: 'Лаборатория температуры',
|
||||
code: 'TEMP_LAB',
|
||||
},
|
||||
date: '2024-01-15',
|
||||
conditions: [
|
||||
{
|
||||
typeName: 'Температура',
|
||||
value: 23.5,
|
||||
unit: '°C',
|
||||
icon: <Thermometer className="h-3 w-3" />,
|
||||
},
|
||||
{
|
||||
typeName: 'Влажность',
|
||||
value: 45.2,
|
||||
unit: '%',
|
||||
icon: <Droplets className="h-3 w-3" />,
|
||||
},
|
||||
{
|
||||
typeName: 'Атмосферное давление',
|
||||
value: 101.3,
|
||||
unit: 'кПа',
|
||||
icon: <Gauge className="h-3 w-3" />,
|
||||
},
|
||||
],
|
||||
onChange?: (config: VerificationConditionsConfig) => void
|
||||
}
|
||||
|
||||
export function VerificationConditionsPreview({
|
||||
config,
|
||||
onChange,
|
||||
}: VerificationConditionsPreviewProps) {
|
||||
const displayedConditions = mockData.conditions.slice(0, config.maxConditions)
|
||||
const [selectedDate, setSelectedDate] = useState(
|
||||
config.selectedDate || new Date().toISOString().split('T')[0]
|
||||
)
|
||||
const [calendarOpen, setCalendarOpen] = useState(false)
|
||||
|
||||
const { data: laboratories = [] } = useLaboratories()
|
||||
const { data: labData } = useLaboratoryConditions(config.selectedLaboratoryId)
|
||||
const { data: dailyCondition, isLoading } = useDailyConditions(
|
||||
config.selectedLaboratoryId,
|
||||
selectedDate
|
||||
)
|
||||
|
||||
const selectedLab = laboratories.find(
|
||||
lab => lab.id === config.selectedLaboratoryId
|
||||
)
|
||||
|
||||
const hasConditions =
|
||||
dailyCondition && Object.keys(dailyCondition.conditions).length > 0
|
||||
|
||||
const handleDateChange = (date: string) => {
|
||||
setSelectedDate(date)
|
||||
if (onChange) {
|
||||
onChange({ ...config, selectedDate: date })
|
||||
}
|
||||
}
|
||||
|
||||
const handleCalendarSelect = (date: Date | undefined) => {
|
||||
if (date) {
|
||||
const dateString = date.toISOString().split('T')[0]
|
||||
setSelectedDate(dateString)
|
||||
setCalendarOpen(false)
|
||||
if (onChange) {
|
||||
onChange({ ...config, selectedDate: dateString })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
if (!labData) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Условия поверки</CardTitle>
|
||||
<Card>
|
||||
<CardHeader className="px-3 pb-1 pt-2">
|
||||
<CardTitle className="flex items-center gap-1.5 text-sm">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{selectedLab?.name || 'Лаборатория'}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Селекторы лаборатории и даты */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<Building2 className="h-3 w-3" />
|
||||
Лаборатория
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/30 p-2 text-sm">
|
||||
{config.selectedLaboratoryId
|
||||
? mockData.laboratory.name
|
||||
: 'Не выбрана'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Дата
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/30 p-2 text-sm">
|
||||
{config.selectedDate || mockData.date}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Условия */}
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Условия
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{displayedConditions.map((condition, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between rounded-md border bg-background p-2 text-sm"
|
||||
<CardContent className="space-y-2 px-3 pb-3">
|
||||
{/* Выбор даты */}
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-6 justify-start border-none bg-muted/50 px-2 text-xs font-normal hover:bg-muted/70"
|
||||
disabled={!onChange}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{condition.icon}
|
||||
<span className="font-medium">{condition.typeName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{condition.value}</span>
|
||||
{config.showUnits && condition.unit && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{condition.unit}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{mockData.conditions.length > config.maxConditions && (
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
... и ещё {mockData.conditions.length - config.maxConditions}{' '}
|
||||
условий
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{new Date(selectedDate).toLocaleDateString('ru-RU')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={new Date(selectedDate)}
|
||||
onSelect={handleCalendarSelect}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Настройки */}
|
||||
<div className="rounded-md bg-muted/50 p-2 text-xs text-muted-foreground">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
Лист: <span className="font-mono">{config.sheet}</span>
|
||||
{/* Статус и данные */}
|
||||
<div className="border-t pt-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span>Загрузка...</span>
|
||||
</div>
|
||||
<div>
|
||||
Ячейка: <span className="font-mono">{config.startCell}</span>
|
||||
) : hasConditions ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-green-600">
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">Данные внесены</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{labData.conditionTypes.map(conditionType => {
|
||||
const conditionValue =
|
||||
dailyCondition!.conditions[conditionType.id]
|
||||
return (
|
||||
<div
|
||||
key={conditionType.id}
|
||||
className="flex items-center justify-between rounded bg-muted/30 px-2 py-1 text-xs"
|
||||
>
|
||||
<span className="truncate font-medium">
|
||||
{conditionType.name}
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-muted-foreground">
|
||||
{conditionValue || '—'}
|
||||
{conditionValue && conditionType.unit && (
|
||||
<span className="ml-1">{conditionType.unit}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>Макс. условий: {config.maxConditions}</div>
|
||||
{config.autoSave && <div>✓ Автосохранение</div>}
|
||||
{config.showUnits && <div>✓ Показывать единицы</div>}
|
||||
{config.targetCells.length > 0 && (
|
||||
<div>Ячеек настроено: {config.targetCells.length}</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-orange-600">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">Данные не внесены</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled
|
||||
size="sm"
|
||||
className="h-6 w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="mr-1.5 h-3 w-3" />
|
||||
Внести данные
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import { Alert, AlertDescription } from '@/component/ui/alert'
|
||||
import { Badge } from '@/component/ui/badge'
|
||||
import { Button } from '@/component/ui/button'
|
||||
import { Calendar } from '@/component/ui/calendar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/component/ui/select'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/component/ui/popover'
|
||||
import {
|
||||
AlertCircle,
|
||||
Building2,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Droplets,
|
||||
Gauge,
|
||||
Loader2,
|
||||
Save,
|
||||
Thermometer,
|
||||
Zap,
|
||||
Calendar as CalendarIcon,
|
||||
CheckCircle,
|
||||
Plus,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { useVerificationConditionsElement } from './hooks'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
useDailyConditions,
|
||||
useLaboratories,
|
||||
useLaboratoryConditions,
|
||||
} from './hooks'
|
||||
import {
|
||||
VerificationConditionsConfig,
|
||||
VerificationConditionsValue,
|
||||
@@ -34,277 +34,352 @@ interface VerificationConditionsRenderProps {
|
||||
config: VerificationConditionsConfig
|
||||
value?: VerificationConditionsValue
|
||||
onChange?: (value: VerificationConditionsValue) => void
|
||||
}
|
||||
|
||||
// Иконки для разных типов условий
|
||||
const getConditionIcon = (conditionName: string) => {
|
||||
const name = conditionName.toLowerCase()
|
||||
if (name.includes('температур')) return <Thermometer className="h-3 w-3" />
|
||||
if (name.includes('влажн')) return <Droplets className="h-3 w-3" />
|
||||
if (name.includes('давлен')) return <Gauge className="h-3 w-3" />
|
||||
if (name.includes('напряжен') || name.includes('частот'))
|
||||
return <Zap className="h-3 w-3" />
|
||||
return <div className="h-3 w-3 rounded-full bg-muted" />
|
||||
onSave?: (value: VerificationConditionsValue) => Promise<void>
|
||||
}
|
||||
|
||||
export function VerificationConditionsRender({
|
||||
config,
|
||||
value,
|
||||
onChange,
|
||||
onSave,
|
||||
}: VerificationConditionsRenderProps) {
|
||||
const [selectedDate, setSelectedDate] = useState(
|
||||
config.selectedDate || new Date().toISOString().split('T')[0]
|
||||
)
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const [calendarOpen, setCalendarOpen] = useState(false)
|
||||
const [editingConditions, setEditingConditions] = useState<
|
||||
Record<string, string>
|
||||
>({})
|
||||
|
||||
const { data: laboratories = [] } = useLaboratories()
|
||||
const { data: labData } = useLaboratoryConditions(config.selectedLaboratoryId)
|
||||
const {
|
||||
laboratories,
|
||||
selectedLaboratory,
|
||||
selectedLaboratoryId,
|
||||
selectedDate,
|
||||
conditions,
|
||||
localConditions,
|
||||
isLoading,
|
||||
isError,
|
||||
isSaving,
|
||||
hasUnsavedChanges,
|
||||
validationErrors,
|
||||
changeLaboratory,
|
||||
changeDate,
|
||||
updateCondition,
|
||||
data: dailyCondition,
|
||||
saveConditions,
|
||||
getCurrentValue,
|
||||
formatValueWithUnit,
|
||||
getConditionKey,
|
||||
} = useVerificationConditionsElement({
|
||||
selectedLaboratoryId: config.selectedLaboratoryId || value?.laboratoryId,
|
||||
selectedDate: config.selectedDate || value?.date,
|
||||
autoSave: config.autoSave,
|
||||
})
|
||||
isSaving,
|
||||
} = useDailyConditions(config.selectedLaboratoryId, selectedDate)
|
||||
|
||||
// Синхронизируем значение с родительским компонентом
|
||||
const selectedLab = laboratories.find(
|
||||
lab => lab.id === config.selectedLaboratoryId
|
||||
)
|
||||
|
||||
const hasConditions =
|
||||
dailyCondition && Object.keys(dailyCondition.conditions).length > 0
|
||||
|
||||
// Автоматическая инициализация onChange при загрузке данных
|
||||
useEffect(() => {
|
||||
if (onChange) {
|
||||
const currentValue = getCurrentValue()
|
||||
if (
|
||||
currentValue &&
|
||||
(!value ||
|
||||
currentValue.laboratoryId !== value.laboratoryId ||
|
||||
currentValue.date !== value.date ||
|
||||
JSON.stringify(currentValue.conditions) !==
|
||||
JSON.stringify(value.conditions))
|
||||
) {
|
||||
onChange(currentValue)
|
||||
}
|
||||
if (dailyCondition && onChange && hasConditions) {
|
||||
onChange({
|
||||
date: selectedDate,
|
||||
conditions: dailyCondition.conditions,
|
||||
})
|
||||
}
|
||||
}, [onChange, getCurrentValue, value])
|
||||
}, [dailyCondition, onChange, selectedDate, hasConditions])
|
||||
|
||||
const handleDateChange = (date: string) => {
|
||||
setSelectedDate(date)
|
||||
}
|
||||
|
||||
const handleCalendarSelect = (date: Date | undefined) => {
|
||||
if (date) {
|
||||
const dateString = date.toISOString().split('T')[0]
|
||||
setSelectedDate(dateString)
|
||||
setCalendarOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Обработка сохранения
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveConditions()
|
||||
await saveConditions(editingConditions)
|
||||
|
||||
if (onChange) {
|
||||
onChange({
|
||||
date: selectedDate,
|
||||
conditions: editingConditions,
|
||||
})
|
||||
}
|
||||
|
||||
setEditDialogOpen(false)
|
||||
} catch (error) {
|
||||
console.error('Ошибка сохранения:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Получение ошибки валидации для поля
|
||||
const getFieldError = (fieldKey: string) => {
|
||||
return validationErrors.find(error => error.field === fieldKey)
|
||||
const handleConditionChange = (conditionId: string, value: string) => {
|
||||
setEditingConditions(prev => ({
|
||||
...prev,
|
||||
[conditionId]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
if (!config.selectedLaboratoryId) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center p-6">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Загрузка...
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="p-3">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<AlertCircle className="mx-auto mb-1 h-4 w-4 opacity-50" />
|
||||
<p className="text-xs">Лаборатория не настроена</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
if (!labData) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Ошибка загрузки данных условий поверки
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between text-sm">
|
||||
<span>Условия поверки</span>
|
||||
{hasUnsavedChanges && !config.autoSave && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || validationErrors.length > 0}
|
||||
className="h-7"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
Сохранить
|
||||
</Button>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader className="px-3 pb-1 pt-2">
|
||||
<CardTitle className="flex items-center gap-1.5 text-sm">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{selectedLab?.name || 'Лаборатория'}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Селекторы лаборатории и даты */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 text-xs font-medium">
|
||||
<Building2 className="h-3 w-3" />
|
||||
Лаборатория
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedLaboratoryId?.toString() || ''}
|
||||
onValueChange={value => changeLaboratory(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Выберите лабораторию" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{laboratories.map(lab => (
|
||||
<SelectItem key={lab.id} value={lab.id.toString()}>
|
||||
<div>
|
||||
<div className="font-medium">{lab.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{lab.code}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 text-xs font-medium">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Дата
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={selectedDate || ''}
|
||||
onChange={e => changeDate(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<CardContent className="space-y-2 px-3 pb-3">
|
||||
{/* Выбор даты */}
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-6 justify-start border-none bg-muted/50 px-2 text-xs font-normal hover:bg-muted/70"
|
||||
>
|
||||
{new Date(selectedDate).toLocaleDateString('ru-RU')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={new Date(selectedDate)}
|
||||
onSelect={handleCalendarSelect}
|
||||
className="rounded-md border shadow-sm"
|
||||
captionLayout="dropdown"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Условия */}
|
||||
{conditions && selectedLaboratoryId && selectedDate && (
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
Условия поверки
|
||||
</Label>
|
||||
<div className="mt-2 space-y-2">
|
||||
{conditions.conditions
|
||||
.slice(0, config.maxConditions)
|
||||
.map((condition, index) => {
|
||||
const key = getConditionKey(condition.typeName)
|
||||
const error = getFieldError(key)
|
||||
{/* Статус и данные */}
|
||||
<div className="border-t pt-2">
|
||||
{hasConditions ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-xs text-green-600">
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">Данные внесены</span>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={condition.typeId}
|
||||
className="flex items-center gap-3 rounded-md border bg-background p-3"
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => handleOpenEditDialog(false)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{getConditionIcon(condition.typeName)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{condition.typeName}
|
||||
</div>
|
||||
{config.showUnits && condition.unit && (
|
||||
<Badge variant="secondary" className="mt-1 text-xs">
|
||||
{condition.unit}
|
||||
</Badge>
|
||||
)}
|
||||
<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="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={localConditions[key] || ''}
|
||||
onChange={e => updateCondition(key, e.target.value)}
|
||||
placeholder={
|
||||
condition.minValue && condition.maxValue
|
||||
? `${condition.minValue}-${condition.maxValue}`
|
||||
: 'Значение'
|
||||
}
|
||||
className={`w-20 text-right ${error ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
{condition.isRequired && (
|
||||
<span className="text-sm text-destructive">*</span>
|
||||
)}
|
||||
<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 (
|
||||
<div
|
||||
key={conditionType.id}
|
||||
className="flex items-center justify-between rounded bg-muted/30 px-2 py-1 text-xs"
|
||||
>
|
||||
<span className="truncate font-medium">
|
||||
{conditionType.name}
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-muted-foreground">
|
||||
{conditionValue || '—'}
|
||||
{conditionValue && conditionType.unit && (
|
||||
<span className="ml-1">{conditionType.unit}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{conditions.conditions.length > config.maxConditions && (
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
... и ещё{' '}
|
||||
{conditions.conditions.length - config.maxConditions} условий
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-orange-600">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">Данные не внесены</span>
|
||||
</div>
|
||||
|
||||
{/* Ошибки валидации */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
{validationErrors.map((error, index) => (
|
||||
<div key={index} className="text-sm">
|
||||
{error.message}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => handleOpenEditDialog(true)}
|
||||
disabled={isSaving}
|
||||
size="sm"
|
||||
className="h-6 w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="mr-1.5 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>
|
||||
))}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Статус */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
{config.autoSave && hasUnsavedChanges && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Автосохранение...
|
||||
</div>
|
||||
)}
|
||||
{config.autoSave &&
|
||||
!hasUnsavedChanges &&
|
||||
selectedLaboratoryId &&
|
||||
selectedDate && (
|
||||
<div className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Сохранено
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedLaboratory && <div>{selectedLaboratory.name}</div>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Подсказка для пустого состояния */}
|
||||
{!selectedLaboratoryId && (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center text-sm text-muted-foreground">
|
||||
<Building2 className="mx-auto mb-2 h-6 w-6 opacity-50" />
|
||||
<p>Выберите лабораторию для настройки условий поверки</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,165 +1,129 @@
|
||||
import {
|
||||
ConditionType,
|
||||
DailyConditions,
|
||||
ElementConditionsData,
|
||||
CreateDailyConditionInput,
|
||||
CreateDailyConditionOutput,
|
||||
DailyCondition,
|
||||
DailyConditionsFilters,
|
||||
GetDailyConditionOutput,
|
||||
GetDailyConditionsOutput,
|
||||
GetLaboratoriesOutput,
|
||||
GetLaboratoryOutput,
|
||||
Laboratory,
|
||||
SaveConditionsResponse,
|
||||
VerificationConditionsValue,
|
||||
UpdateDailyConditionInput,
|
||||
UpdateDailyConditionOutput,
|
||||
} from './types'
|
||||
|
||||
const BASE_URL = '/api/v1/verification-conditions'
|
||||
// Базовый 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`)
|
||||
const response = await fetch(`${BASE_URL}/laboratories/`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка загрузки лабораторий')
|
||||
}
|
||||
return response.json()
|
||||
const data: GetLaboratoriesOutput = await response.json()
|
||||
return data.laboratories
|
||||
},
|
||||
|
||||
// Получить лабораторию по ID
|
||||
getById: async (id: number): Promise<Laboratory> => {
|
||||
getById: async (id: string): Promise<Laboratory | null> => {
|
||||
const response = await fetch(`${BASE_URL}/laboratories/${id}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Лаборатория не найдена')
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
throw new Error('Ошибка получения лаборатории')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// Создать лабораторию
|
||||
create: async (
|
||||
data: Omit<Laboratory, 'id' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<Laboratory> => {
|
||||
const response = await fetch(`${BASE_URL}/laboratories`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка создания лаборатории')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
}
|
||||
|
||||
// API для типов условий
|
||||
export const conditionTypesApi = {
|
||||
// Получить все типы условий
|
||||
getAll: async (): Promise<ConditionType[]> => {
|
||||
const response = await fetch(`${BASE_URL}/condition-types`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка загрузки типов условий')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// Создать тип условия
|
||||
create: async (
|
||||
data: Omit<ConditionType, 'id' | 'createdAt'>
|
||||
): Promise<ConditionType> => {
|
||||
const response = await fetch(`${BASE_URL}/condition-types`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка создания типа условия')
|
||||
}
|
||||
return response.json()
|
||||
const data: GetLaboratoryOutput = await response.json()
|
||||
return data.laboratory
|
||||
},
|
||||
}
|
||||
|
||||
// API для ежедневных условий
|
||||
export const dailyConditionsApi = {
|
||||
// Получить условия по фильтрам
|
||||
get: async (params: {
|
||||
laboratoryId?: number
|
||||
date?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
}): Promise<DailyConditions[]> => {
|
||||
get: async (
|
||||
filters: DailyConditionsFilters = {}
|
||||
): Promise<DailyCondition[]> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) searchParams.append(key, value.toString())
|
||||
})
|
||||
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 response = await fetch(`${BASE_URL}/daily-conditions?${searchParams}`)
|
||||
const url = searchParams.toString()
|
||||
? `${BASE_URL}/daily-conditions/?${searchParams}`
|
||||
: `${BASE_URL}/daily-conditions/`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка загрузки условий')
|
||||
}
|
||||
return response.json()
|
||||
const data: GetDailyConditionsOutput = await response.json()
|
||||
return data.daily_conditions
|
||||
},
|
||||
|
||||
// Получить условия для элемента
|
||||
getForElement: async (
|
||||
laboratoryId: number,
|
||||
date: string
|
||||
): Promise<ElementConditionsData> => {
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/daily-conditions/for-element?laboratoryId=${laboratoryId}&date=${date}`
|
||||
)
|
||||
// Получить конкретное условие по ID
|
||||
getById: async (id: string): Promise<DailyCondition | null> => {
|
||||
const response = await fetch(`${BASE_URL}/daily-conditions/${id}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка загрузки условий для элемента')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// Сохранить условия из элемента
|
||||
saveForElement: async (
|
||||
data: VerificationConditionsValue
|
||||
): Promise<SaveConditionsResponse> => {
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/daily-conditions/save-for-element`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Ошибка сохранения условий')
|
||||
throw new Error('Ошибка получения условия')
|
||||
}
|
||||
return response.json()
|
||||
const data: GetDailyConditionOutput = await response.json()
|
||||
return data.daily_condition
|
||||
},
|
||||
|
||||
// Создать/обновить условия
|
||||
save: async (
|
||||
data: Omit<DailyConditions, 'id' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<DailyConditions> => {
|
||||
const response = await fetch(`${BASE_URL}/daily-conditions`, {
|
||||
// Создать новые условия
|
||||
create: async (input: CreateDailyConditionInput): Promise<string> => {
|
||||
const response = await fetch(`${BASE_URL}/daily-conditions/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ message: 'Ошибка сохранения условий' }))
|
||||
throw new Error(error.message || 'Ошибка сохранения условий')
|
||||
}
|
||||
return response.json()
|
||||
const data: CreateDailyConditionOutput = await response.json()
|
||||
return data.id
|
||||
},
|
||||
|
||||
// Обновить условия
|
||||
update: async (
|
||||
id: number,
|
||||
data: Partial<DailyConditions>
|
||||
): Promise<DailyConditions> => {
|
||||
id: string,
|
||||
input: UpdateDailyConditionInput
|
||||
): Promise<string> => {
|
||||
const response = await fetch(`${BASE_URL}/daily-conditions/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка обновления условий')
|
||||
}
|
||||
return response.json()
|
||||
const data: UpdateDailyConditionOutput = await response.json()
|
||||
return data.id
|
||||
},
|
||||
|
||||
// Удалить условия
|
||||
delete: async (id: number): Promise<void> => {
|
||||
delete: async (id: string): Promise<void> => {
|
||||
const response = await fetch(`${BASE_URL}/daily-conditions/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
@@ -169,12 +133,83 @@ export const dailyConditionsApi = {
|
||||
},
|
||||
}
|
||||
|
||||
// Комбинированный 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,
|
||||
dataType: string = 'string',
|
||||
minValue?: number,
|
||||
maxValue?: number
|
||||
): { isValid: boolean; error?: string } => {
|
||||
@@ -201,11 +236,23 @@ export const verificationConditionsUtils = {
|
||||
return unit ? `${value} ${unit}` : value.toString()
|
||||
},
|
||||
|
||||
// Получение ключа условия для хранения в conditions JSON
|
||||
getConditionKey: (conditionTypeName: string): string => {
|
||||
// Получение ключа условия для хранения в 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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||
import { CellTarget } from '@/type/template'
|
||||
import { Gauge } from 'lucide-react'
|
||||
import { Building2 } from 'lucide-react'
|
||||
import {
|
||||
VerificationConditionsConfig,
|
||||
VerificationConditionsValue,
|
||||
@@ -9,70 +9,68 @@ import { VerificationConditionsEditor } from './VerificationConditionsEditor'
|
||||
import { VerificationConditionsPreview } from './VerificationConditionsPreview'
|
||||
import { VerificationConditionsRender } from './VerificationConditionsRender'
|
||||
|
||||
export const verificationConditionsDefinition: ElementDefinition<
|
||||
VerificationConditionsConfig,
|
||||
export const verificationConditionsElementDefinition: ElementDefinition<
|
||||
VerificationConditionsConfig & { targetCells?: CellTarget[] },
|
||||
VerificationConditionsValue
|
||||
> = {
|
||||
type: 'verification_conditions',
|
||||
label: 'Условия поверки',
|
||||
icon: <Gauge className="h-4 w-4" />,
|
||||
icon: <Building2 className="h-4 w-4" />,
|
||||
version: 1,
|
||||
|
||||
defaultConfig: {
|
||||
targetCells: [],
|
||||
selectedLaboratoryId: undefined,
|
||||
selectedDate: undefined,
|
||||
conditionMappings: [],
|
||||
showUnits: true,
|
||||
targetCells: [],
|
||||
},
|
||||
|
||||
// Компоненты
|
||||
Editor: VerificationConditionsEditor,
|
||||
Preview: VerificationConditionsPreview,
|
||||
Render: VerificationConditionsRender,
|
||||
|
||||
mapToCells: config => {
|
||||
// Возвращаем настроенные пользователем целевые ячейки
|
||||
return config.targetCells.map(cell => ({
|
||||
sheet: cell.sheet,
|
||||
cell: cell.cell,
|
||||
}))
|
||||
// Получение ячеек для записи данных на основе маппинга условий
|
||||
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) => {
|
||||
if (!value) return []
|
||||
// Получение значения для записи в ячейки на основе реального маппинга
|
||||
mapToCellValues: (
|
||||
config,
|
||||
value: VerificationConditionsValue
|
||||
): Array<{ target: CellTarget; value: any }> => {
|
||||
if (!config.conditionMappings || !config.targetCells || !value.conditions) {
|
||||
return []
|
||||
}
|
||||
|
||||
const targetCells = config.targetCells || []
|
||||
const mappings = config.conditionMappings || []
|
||||
if (targetCells.length === 0) return []
|
||||
|
||||
const results: Array<{ target: CellTarget; value: any }> = []
|
||||
|
||||
// Записываем данные в ячейки согласно настроенным привязкам
|
||||
mappings.forEach(mapping => {
|
||||
const targetCell = targetCells[mapping.cellIndex]
|
||||
if (!targetCell) return
|
||||
|
||||
let cellValue: any = ''
|
||||
|
||||
switch (mapping.conditionKey) {
|
||||
case 'laboratory':
|
||||
cellValue = `Лаборатория ${value.laboratoryId}` // Здесь можно подставить реальное название
|
||||
break
|
||||
case 'date':
|
||||
cellValue = value.date
|
||||
break
|
||||
default:
|
||||
// Это условие из лаборатории
|
||||
cellValue = value.conditions[mapping.conditionKey] || ''
|
||||
break
|
||||
}
|
||||
|
||||
results.push({
|
||||
target: {
|
||||
sheet: targetCell.sheet,
|
||||
cell: targetCell.cell,
|
||||
},
|
||||
value: cellValue,
|
||||
return config.conditionMappings
|
||||
.filter(mapping => {
|
||||
const conditionValue = value.conditions[mapping.conditionKey]
|
||||
// Записываем только непустые значения
|
||||
return (
|
||||
conditionValue !== undefined &&
|
||||
conditionValue !== null &&
|
||||
String(conditionValue).trim() !== ''
|
||||
)
|
||||
})
|
||||
})
|
||||
.map(mapping => {
|
||||
const targetCell = config.targetCells?.[mapping.cellIndex]
|
||||
if (!targetCell) return null
|
||||
|
||||
return results
|
||||
return {
|
||||
target: targetCell,
|
||||
value: value.conditions[mapping.conditionKey],
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Array<{ target: CellTarget; value: any }>
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { mockApiHelpers, mockLaboratories } from './mockData'
|
||||
import {
|
||||
ElementConditionsData,
|
||||
Laboratory,
|
||||
ValidationError,
|
||||
VerificationConditionsValue,
|
||||
} from './types'
|
||||
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)
|
||||
@@ -17,9 +12,9 @@ export function useLaboratories() {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
await mockApiHelpers.delay(300) // Симуляция загрузки
|
||||
setData(mockLaboratories)
|
||||
setIsError(false)
|
||||
const laboratories = await laboratoriesApi.getAll()
|
||||
setData(laboratories)
|
||||
} catch (error) {
|
||||
setIsError(true)
|
||||
console.error('Ошибка загрузки лабораторий:', error)
|
||||
@@ -34,25 +29,127 @@ export function useLaboratories() {
|
||||
return { data, isLoading, isError }
|
||||
}
|
||||
|
||||
// Хук для загрузки конкретной лаборатории
|
||||
export function useLaboratory(id: number | undefined) {
|
||||
const [data, setData] = useState<Laboratory | undefined>()
|
||||
// Хук для работы с ежедневными условиями
|
||||
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 (!id) {
|
||||
setData(undefined)
|
||||
if (!laboratoryId || !date) {
|
||||
setData(null)
|
||||
return
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
await mockApiHelpers.delay(200)
|
||||
const laboratory = mockApiHelpers.getLaboratoryById(id)
|
||||
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)
|
||||
setIsError(!laboratory)
|
||||
} catch (error) {
|
||||
setIsError(true)
|
||||
console.error('Ошибка загрузки лаборатории:', error)
|
||||
@@ -62,370 +159,60 @@ export function useLaboratory(id: number | undefined) {
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [id])
|
||||
}, [laboratoryId])
|
||||
|
||||
return { data, isLoading, isError }
|
||||
}
|
||||
|
||||
// Хук для загрузки условий для элемента
|
||||
export function useElementConditions(
|
||||
laboratoryId: number | undefined,
|
||||
date: string | undefined
|
||||
) {
|
||||
const [data, setData] = useState<ElementConditionsData | undefined>()
|
||||
// Хук для управления ежедневными условиями с расширенным функционалом
|
||||
export function useDailyConditionsManager() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isError, setIsError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!laboratoryId || !date) {
|
||||
setData(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
const loadConditions = useCallback(
|
||||
async (filters: {
|
||||
laboratoryId?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}) => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
await mockApiHelpers.delay(400)
|
||||
const conditions = mockApiHelpers.getElementConditions(
|
||||
laboratoryId,
|
||||
date
|
||||
)
|
||||
setData(conditions)
|
||||
setIsError(!conditions)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [laboratoryId, date])
|
||||
|
||||
return { data, isLoading, isError }
|
||||
}
|
||||
|
||||
// Хук для сохранения условий
|
||||
export function useSaveConditions() {
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const [isError, setIsError] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const mutateAsync = useCallback(
|
||||
async (value: VerificationConditionsValue) => {
|
||||
try {
|
||||
setIsPending(true)
|
||||
setIsError(false)
|
||||
setError(null)
|
||||
|
||||
await mockApiHelpers.delay(800) // Симуляция сохранения
|
||||
|
||||
// Симуляция возможной ошибки (5% вероятность)
|
||||
if (Math.random() < 0.05) {
|
||||
throw new Error('Ошибка сети при сохранении')
|
||||
}
|
||||
|
||||
console.log('Сохранены условия:', value)
|
||||
return {
|
||||
id: Math.floor(Math.random() * 1000),
|
||||
success: true,
|
||||
message: 'Условия успешно сохранены',
|
||||
}
|
||||
} catch (err) {
|
||||
const error =
|
||||
err instanceof Error ? err : new Error('Неизвестная ошибка')
|
||||
setIsError(true)
|
||||
setError(error)
|
||||
throw error
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return { mutateAsync, isPending, isError, error }
|
||||
}
|
||||
|
||||
// Вспомогательная функция для валидации
|
||||
const validateConditionValue = (
|
||||
value: any,
|
||||
dataType: string,
|
||||
minValue?: number,
|
||||
maxValue?: number
|
||||
): { isValid: boolean; error?: string } => {
|
||||
if (dataType === 'number') {
|
||||
const numValue = parseFloat(value)
|
||||
if (isNaN(numValue)) {
|
||||
return { isValid: false, error: 'Должно быть числом' }
|
||||
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)
|
||||
}
|
||||
if (minValue !== undefined && numValue < minValue) {
|
||||
return { isValid: false, error: `Минимальное значение: ${minValue}` }
|
||||
}
|
||||
if (maxValue !== undefined && numValue > maxValue) {
|
||||
return { isValid: false, error: `Максимальное значение: ${maxValue}` }
|
||||
}
|
||||
}
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
// Вспомогательная функция для получения ключа условия
|
||||
const getConditionKey = (conditionTypeName: string): string => {
|
||||
return conditionTypeName
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^\w]/g, '')
|
||||
}
|
||||
|
||||
// Основной хук для управления состоянием элемента условий поверки
|
||||
export function useVerificationConditionsElement(
|
||||
initialConfig: {
|
||||
selectedLaboratoryId?: number
|
||||
selectedDate?: string
|
||||
autoSave?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const [selectedLaboratoryId, setSelectedLaboratoryId] = useState<
|
||||
number | undefined
|
||||
>(initialConfig.selectedLaboratoryId)
|
||||
const [selectedDate, setSelectedDate] = useState<string | undefined>(
|
||||
initialConfig.selectedDate || new Date().toISOString().split('T')[0]
|
||||
)
|
||||
const [localConditions, setLocalConditions] = useState<Record<string, any>>(
|
||||
{}
|
||||
)
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationError[]>(
|
||||
[]
|
||||
)
|
||||
|
||||
// Загружаем данные
|
||||
const laboratoriesQuery = useLaboratories()
|
||||
const elementConditionsQuery = useElementConditions(
|
||||
selectedLaboratoryId,
|
||||
selectedDate
|
||||
)
|
||||
const saveConditionsMutation = useSaveConditions()
|
||||
|
||||
// Получаем выбранную лабораторию
|
||||
const selectedLaboratory = laboratoriesQuery.data?.find(
|
||||
lab => lab.id === selectedLaboratoryId
|
||||
)
|
||||
|
||||
// Синхронизируем локальные условия с загруженными данными
|
||||
useEffect(() => {
|
||||
if (elementConditionsQuery.data && !hasUnsavedChanges) {
|
||||
const conditions: Record<string, any> = {}
|
||||
elementConditionsQuery.data.conditions.forEach(cond => {
|
||||
const key = getConditionKey(cond.typeName)
|
||||
conditions[key] = cond.value
|
||||
})
|
||||
setLocalConditions(conditions)
|
||||
}
|
||||
}, [elementConditionsQuery.data, hasUnsavedChanges])
|
||||
|
||||
// Валидация условий
|
||||
const validateConditions = useCallback(
|
||||
(conditions: Record<string, any>): ValidationError[] => {
|
||||
const errors: ValidationError[] = []
|
||||
|
||||
if (!elementConditionsQuery.data) return errors
|
||||
|
||||
elementConditionsQuery.data.conditions.forEach(conditionDef => {
|
||||
const key = getConditionKey(conditionDef.typeName)
|
||||
const value = conditions[key]
|
||||
|
||||
// Проверка обязательных полей
|
||||
if (
|
||||
conditionDef.isRequired &&
|
||||
(value === undefined || value === null || value === '')
|
||||
) {
|
||||
errors.push({
|
||||
field: key,
|
||||
message: `${conditionDef.typeName} обязательно для заполнения`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация значения
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
const validation = validateConditionValue(
|
||||
value,
|
||||
'number',
|
||||
conditionDef.minValue,
|
||||
conditionDef.maxValue
|
||||
)
|
||||
|
||||
if (!validation.isValid) {
|
||||
errors.push({
|
||||
field: key,
|
||||
message: validation.error!,
|
||||
value,
|
||||
min: conditionDef.minValue,
|
||||
max: conditionDef.maxValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return errors
|
||||
},
|
||||
[elementConditionsQuery.data]
|
||||
)
|
||||
|
||||
// Обновление значения условия
|
||||
const updateCondition = useCallback(
|
||||
(key: string, value: any) => {
|
||||
setLocalConditions(prev => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}))
|
||||
setHasUnsavedChanges(true)
|
||||
|
||||
// Валидируем новые условия
|
||||
const newConditions = { ...localConditions, [key]: value }
|
||||
const errors = validateConditions(newConditions)
|
||||
setValidationErrors(errors)
|
||||
},
|
||||
[localConditions, validateConditions]
|
||||
)
|
||||
|
||||
// Смена лаборатории
|
||||
const changeLaboratory = useCallback(
|
||||
(laboratoryId: number) => {
|
||||
if (hasUnsavedChanges) {
|
||||
const confirmed = window.confirm(
|
||||
'У вас есть несохранённые изменения. Продолжить без сохранения?'
|
||||
)
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
setSelectedLaboratoryId(laboratoryId)
|
||||
setLocalConditions({})
|
||||
setHasUnsavedChanges(false)
|
||||
setValidationErrors([])
|
||||
},
|
||||
[hasUnsavedChanges]
|
||||
)
|
||||
|
||||
// Смена даты
|
||||
const changeDate = useCallback(
|
||||
(date: string) => {
|
||||
if (hasUnsavedChanges) {
|
||||
const confirmed = window.confirm(
|
||||
'У вас есть несохранённые изменения. Продолжить без сохранения?'
|
||||
)
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
setSelectedDate(date)
|
||||
setLocalConditions({})
|
||||
setHasUnsavedChanges(false)
|
||||
setValidationErrors([])
|
||||
},
|
||||
[hasUnsavedChanges]
|
||||
)
|
||||
|
||||
// Сохранение условий
|
||||
const saveConditions = useCallback(async () => {
|
||||
if (!selectedLaboratoryId || !selectedDate) {
|
||||
throw new Error('Не выбрана лаборатория или дата')
|
||||
}
|
||||
|
||||
const errors = validateConditions(localConditions)
|
||||
if (errors.length > 0) {
|
||||
setValidationErrors(errors)
|
||||
throw new Error('Есть ошибки валидации')
|
||||
}
|
||||
|
||||
const value: VerificationConditionsValue = {
|
||||
laboratoryId: selectedLaboratoryId,
|
||||
date: selectedDate,
|
||||
conditions: localConditions,
|
||||
}
|
||||
|
||||
const result = await saveConditionsMutation.mutateAsync(value)
|
||||
setHasUnsavedChanges(false)
|
||||
setValidationErrors([])
|
||||
return result
|
||||
}, [
|
||||
selectedLaboratoryId,
|
||||
selectedDate,
|
||||
localConditions,
|
||||
validateConditions,
|
||||
saveConditionsMutation,
|
||||
])
|
||||
|
||||
// Автосохранение
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialConfig.autoSave &&
|
||||
hasUnsavedChanges &&
|
||||
validationErrors.length === 0
|
||||
) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
saveConditions().catch(console.error)
|
||||
}, 2000) // Автосохранение через 2 секунды
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [
|
||||
initialConfig.autoSave,
|
||||
hasUnsavedChanges,
|
||||
validationErrors.length,
|
||||
saveConditions,
|
||||
])
|
||||
|
||||
// Получение текущего значения для компонента
|
||||
const getCurrentValue = useCallback(():
|
||||
| VerificationConditionsValue
|
||||
| undefined => {
|
||||
if (!selectedLaboratoryId || !selectedDate) return undefined
|
||||
|
||||
return {
|
||||
laboratoryId: selectedLaboratoryId,
|
||||
date: selectedDate,
|
||||
conditions: localConditions,
|
||||
}
|
||||
}, [selectedLaboratoryId, selectedDate, localConditions])
|
||||
|
||||
// Форматирование значения с единицей измерения
|
||||
const formatValueWithUnit = useCallback(
|
||||
(value: any, unit?: string): string => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return '-'
|
||||
}
|
||||
return unit ? `${value} ${unit}` : value.toString()
|
||||
},
|
||||
[]
|
||||
)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Данные
|
||||
laboratories: laboratoriesQuery.data || [],
|
||||
selectedLaboratory,
|
||||
selectedLaboratoryId,
|
||||
selectedDate,
|
||||
conditions: elementConditionsQuery.data,
|
||||
localConditions,
|
||||
|
||||
// Состояние
|
||||
isLoading: laboratoriesQuery.isLoading || elementConditionsQuery.isLoading,
|
||||
isError: laboratoriesQuery.isError || elementConditionsQuery.isError,
|
||||
isSaving: saveConditionsMutation.isPending,
|
||||
hasUnsavedChanges,
|
||||
validationErrors,
|
||||
|
||||
// Действия
|
||||
changeLaboratory,
|
||||
changeDate,
|
||||
updateCondition,
|
||||
saveConditions,
|
||||
getCurrentValue,
|
||||
|
||||
// Вспомогательные функции
|
||||
formatValueWithUnit,
|
||||
getConditionKey,
|
||||
isLoading,
|
||||
isError,
|
||||
loadConditions,
|
||||
deleteConditions,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { verificationConditionsDefinition } from './definition'
|
||||
export { verificationConditionsElementDefinition as verificationConditionsDefinition } from './definition'
|
||||
export { VerificationConditionsEditor } from './VerificationConditionsEditor'
|
||||
export { VerificationConditionsPreview } from './VerificationConditionsPreview'
|
||||
export { VerificationConditionsRender } from './VerificationConditionsRender'
|
||||
|
||||
@@ -1,280 +1,2 @@
|
||||
import { ConditionType, Laboratory } from './types'
|
||||
|
||||
// Моковые типы условий (из БД схемы)
|
||||
export const mockConditionTypes: ConditionType[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Температура',
|
||||
unit: '°C',
|
||||
dataType: 'number',
|
||||
description: 'Температура окружающей среды',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Влажность',
|
||||
unit: '%',
|
||||
dataType: 'number',
|
||||
description: 'Относительная влажность воздуха',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Атмосферное давление',
|
||||
unit: 'кПа',
|
||||
dataType: 'number',
|
||||
description: 'Атмосферное давление',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Давление среды',
|
||||
unit: 'МПа',
|
||||
dataType: 'number',
|
||||
description: 'Давление рабочей среды',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Напряжение сети',
|
||||
unit: 'В',
|
||||
dataType: 'number',
|
||||
description: 'Напряжение питающей сети',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Частота сети',
|
||||
unit: 'Гц',
|
||||
dataType: 'number',
|
||||
description: 'Частота питающей сети',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
// Моковые лаборатории с полными данными условий
|
||||
export const mockLaboratories: Laboratory[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Лаборатория температуры',
|
||||
code: 'TEMP_LAB',
|
||||
description: 'Лаборатория поверки температурных приборов',
|
||||
address: 'Корпус А, комната 101',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
conditionTypes: [
|
||||
{
|
||||
id: 1,
|
||||
laboratoryId: 1,
|
||||
conditionTypeId: 1,
|
||||
conditionType: mockConditionTypes[0], // Температура
|
||||
isRequired: true,
|
||||
defaultValue: '20',
|
||||
minValue: 15,
|
||||
maxValue: 30,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
laboratoryId: 1,
|
||||
conditionTypeId: 2,
|
||||
conditionType: mockConditionTypes[1], // Влажность
|
||||
isRequired: true,
|
||||
defaultValue: '50',
|
||||
minValue: 30,
|
||||
maxValue: 80,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
laboratoryId: 1,
|
||||
conditionTypeId: 3,
|
||||
conditionType: mockConditionTypes[2], // Атмосферное давление
|
||||
isRequired: false,
|
||||
defaultValue: '101.3',
|
||||
minValue: 80,
|
||||
maxValue: 120,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Лаборатория давления',
|
||||
code: 'PRESS_LAB',
|
||||
description: 'Лаборатория поверки приборов давления',
|
||||
address: 'Корпус Б, комната 205',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
conditionTypes: [
|
||||
{
|
||||
id: 4,
|
||||
laboratoryId: 2,
|
||||
conditionTypeId: 1,
|
||||
conditionType: mockConditionTypes[0], // Температура
|
||||
isRequired: true,
|
||||
defaultValue: '23',
|
||||
minValue: 18,
|
||||
maxValue: 28,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
laboratoryId: 2,
|
||||
conditionTypeId: 2,
|
||||
conditionType: mockConditionTypes[1], // Влажность
|
||||
isRequired: false,
|
||||
defaultValue: '45',
|
||||
minValue: 20,
|
||||
maxValue: 70,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
laboratoryId: 2,
|
||||
conditionTypeId: 3,
|
||||
conditionType: mockConditionTypes[2], // Атмосферное давление
|
||||
isRequired: true,
|
||||
defaultValue: '101.3',
|
||||
minValue: 90,
|
||||
maxValue: 110,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
laboratoryId: 2,
|
||||
conditionTypeId: 4,
|
||||
conditionType: mockConditionTypes[3], // Давление среды
|
||||
isRequired: true,
|
||||
defaultValue: '0.1',
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Электрическая лаборатория',
|
||||
code: 'ELEC_LAB',
|
||||
description: 'Лаборатория поверки электрических приборов',
|
||||
address: 'Корпус В, комната 310',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
conditionTypes: [
|
||||
{
|
||||
id: 8,
|
||||
laboratoryId: 3,
|
||||
conditionTypeId: 1,
|
||||
conditionType: mockConditionTypes[0], // Температура
|
||||
isRequired: true,
|
||||
defaultValue: '22',
|
||||
minValue: 20,
|
||||
maxValue: 25,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
laboratoryId: 3,
|
||||
conditionTypeId: 2,
|
||||
conditionType: mockConditionTypes[1], // Влажность
|
||||
isRequired: true,
|
||||
defaultValue: '60',
|
||||
minValue: 40,
|
||||
maxValue: 70,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
laboratoryId: 3,
|
||||
conditionTypeId: 5,
|
||||
conditionType: mockConditionTypes[4], // Напряжение сети
|
||||
isRequired: true,
|
||||
defaultValue: '220',
|
||||
minValue: 198,
|
||||
maxValue: 242,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
laboratoryId: 3,
|
||||
conditionTypeId: 6,
|
||||
conditionType: mockConditionTypes[5], // Частота сети
|
||||
isRequired: true,
|
||||
defaultValue: '50',
|
||||
minValue: 49.5,
|
||||
maxValue: 50.5,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Функция для получения всех доступных условий (для выбора в элементе)
|
||||
export const getAllAvailableConditions = () => {
|
||||
return [
|
||||
{
|
||||
key: 'laboratory',
|
||||
name: 'Название лаборатории',
|
||||
unit: '',
|
||||
dataType: 'string' as const,
|
||||
},
|
||||
{ key: 'date', name: 'Дата', unit: '', dataType: 'string' as const },
|
||||
...mockConditionTypes.map(type => ({
|
||||
key: type.name.toLowerCase().replace(/\s+/g, '_').replace(/[^\w]/g, ''),
|
||||
name: type.name,
|
||||
unit: type.unit || '',
|
||||
dataType: type.dataType,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
// Упрощенный API helper
|
||||
export const mockApiHelpers = {
|
||||
// Получить все лаборатории
|
||||
getLaboratories: async (): Promise<Laboratory[]> => {
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
return mockLaboratories
|
||||
},
|
||||
|
||||
// Получить лабораторию по ID
|
||||
getLaboratoryById: (id: number): Laboratory | undefined => {
|
||||
return mockLaboratories.find(lab => lab.id === id)
|
||||
},
|
||||
|
||||
// Получить все доступные условия
|
||||
getAllConditions: () => getAllAvailableConditions(),
|
||||
|
||||
// Получить условия конкретной лаборатории
|
||||
getLaboratoryConditions: (laboratoryId: number) => {
|
||||
const lab = mockLaboratories.find(l => l.id === laboratoryId)
|
||||
if (!lab) return []
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'laboratory',
|
||||
name: 'Название лаборатории',
|
||||
unit: '',
|
||||
dataType: 'string' as const,
|
||||
},
|
||||
{ key: 'date', name: 'Дата', unit: '', dataType: 'string' as const },
|
||||
...(lab.conditionTypes?.map(ct => ({
|
||||
key:
|
||||
ct.conditionType?.name
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^\w]/g, '') || `condition_${ct.id}`,
|
||||
name: ct.conditionType?.name || `Условие ${ct.id}`,
|
||||
unit: ct.conditionType?.unit || '',
|
||||
dataType: ct.conditionType?.dataType || ('string' as const),
|
||||
isRequired: ct.isRequired,
|
||||
defaultValue: ct.defaultValue,
|
||||
minValue: ct.minValue,
|
||||
maxValue: ct.maxValue,
|
||||
})) || []),
|
||||
]
|
||||
},
|
||||
|
||||
// Симуляция задержки API
|
||||
delay: (ms: number = 500) => new Promise(resolve => setTimeout(resolve, ms)),
|
||||
}
|
||||
// Флаг для переключения между mock и реальными данными
|
||||
export const USE_MOCK_DATA = false
|
||||
|
||||
@@ -1,109 +1,101 @@
|
||||
// Типы для элемента "Условия поверки"
|
||||
// Типы для элемента "Условия поверки" на основе новой API схемы
|
||||
|
||||
export interface Laboratory {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
description?: string
|
||||
address?: string
|
||||
conditionTypes?: LaboratoryConditionType[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ConditionType {
|
||||
id: number
|
||||
name: string
|
||||
unit?: string
|
||||
dataType: 'number' | 'string' | 'boolean'
|
||||
description?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LaboratoryConditionType {
|
||||
id: number
|
||||
laboratoryId: number
|
||||
conditionTypeId: number
|
||||
conditionType?: ConditionType
|
||||
isRequired: boolean
|
||||
defaultValue?: string
|
||||
minValue?: number
|
||||
maxValue?: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface DailyConditions {
|
||||
id: number
|
||||
laboratoryId: number
|
||||
laboratory?: Laboratory
|
||||
date: string // YYYY-MM-DD
|
||||
conditions: Record<string, any> // JSON объект с условиями
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
createdBy?: string
|
||||
}
|
||||
|
||||
export interface ConditionValue {
|
||||
typeId: number
|
||||
typeName: string
|
||||
unit?: string
|
||||
value: any
|
||||
isRequired: boolean
|
||||
minValue?: number
|
||||
maxValue?: number
|
||||
}
|
||||
|
||||
export interface ElementConditionsData {
|
||||
laboratoryId: number
|
||||
laboratoryName: string
|
||||
date: string
|
||||
conditions: ConditionValue[]
|
||||
hasUnsavedChanges: boolean
|
||||
}
|
||||
|
||||
// Привязка условия к ячейке
|
||||
export interface ConditionCellMapping {
|
||||
cellIndex: number // Индекс целевой ячейки
|
||||
conditionKey: string // Ключ условия ('laboratory', 'date', 'temperature', etc.)
|
||||
conditionName: string // Название для отображения
|
||||
// Маппинг условия в ячейку
|
||||
export interface ConditionMapping {
|
||||
conditionKey: string // ключ условия (temperature, humidity, etc.)
|
||||
conditionName: string // название условия для отображения
|
||||
cellIndex: number // индекс целевой ячейки
|
||||
}
|
||||
|
||||
// Конфигурация элемента
|
||||
export interface VerificationConditionsConfig {
|
||||
selectedLaboratoryId?: number // Выбранная лаборатория
|
||||
targetCells: Array<{
|
||||
sheet: string
|
||||
cell: string
|
||||
}> // Целевые ячейки из основного редактора элементов
|
||||
conditionMappings: ConditionCellMapping[] // Привязка условий к ячейкам
|
||||
// Выбранная лаборатория (UUID)
|
||||
selectedLaboratoryId?: string
|
||||
// Выбранная дата
|
||||
selectedDate?: string
|
||||
// Маппинг условий в ячейки
|
||||
conditionMappings?: ConditionMapping[]
|
||||
// Показывать ли единицы измерения
|
||||
showUnits?: boolean
|
||||
}
|
||||
|
||||
// Значение элемента
|
||||
export interface VerificationConditionsValue {
|
||||
laboratoryId: number
|
||||
date: string
|
||||
conditions: Record<string, any> // Значения условий {temperature: 23.5, humidity: 45.2}
|
||||
date: string // дата в формате YYYY-MM-DD
|
||||
conditions: Record<string, string> // условия поверки {temperature: "20", humidity: "50", ...}
|
||||
}
|
||||
|
||||
// Ответы API
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
data?: T
|
||||
message?: string
|
||||
error?: string
|
||||
// Тип условия из API
|
||||
export interface ConditionType {
|
||||
id: string
|
||||
name: string
|
||||
unit: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
deleted_at: string | null
|
||||
}
|
||||
|
||||
export interface SaveConditionsResponse {
|
||||
id: number
|
||||
success: boolean
|
||||
message: string
|
||||
// Лаборатория из API
|
||||
export interface Laboratory {
|
||||
id: string
|
||||
name: string
|
||||
condition_types: ConditionType[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
deleted_at: string | null
|
||||
}
|
||||
|
||||
// Ошибки валидации
|
||||
export interface ValidationError {
|
||||
field: string
|
||||
message: string
|
||||
value?: any
|
||||
min?: number
|
||||
max?: number
|
||||
// Ежедневное условие из 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
|
||||
}
|
||||
|
||||
213
src/component/ui/calendar.tsx
Normal file
213
src/component/ui/calendar.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
|
||||
|
||||
import { Button, buttonVariants } from '@/component/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = 'label',
|
||||
buttonVariant = 'ghost',
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant']
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: date =>
|
||||
date.toLocaleString('default', { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn('w-fit', defaultClassNames.root),
|
||||
months: cn(
|
||||
'flex gap-4 flex-col md:flex-row relative',
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
|
||||
nav: cn(
|
||||
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
'absolute bg-popover inset-0 opacity-0',
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
'select-none font-medium',
|
||||
captionLayout === 'label'
|
||||
? 'text-sm'
|
||||
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: 'w-full border-collapse',
|
||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn('flex w-full mt-2', defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
'select-none w-(--cell-size)',
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
'text-[0.8rem] select-none text-muted-foreground',
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
'rounded-l-md bg-accent',
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
||||
today: cn(
|
||||
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
'text-muted-foreground aria-selected:text-muted-foreground',
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
'text-muted-foreground opacity-50',
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn('invisible', defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === 'left') {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === 'right') {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn('size-4', className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="size-(--cell-size) flex items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
'min-w-(--cell-size) flex aspect-square size-auto w-full flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-start=true]:rounded-l-md data-[range-end=true]:bg-primary data-[range-middle=true]:bg-accent data-[range-start=true]:bg-primary data-[selected-single=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:text-accent-foreground data-[range-start=true]:text-primary-foreground data-[selected-single=true]:text-primary-foreground group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
@@ -6,18 +6,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/component/ui/select'
|
||||
import { CellTarget } from '@/type/template'
|
||||
import { Info, Settings } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { StandardsSelector } from './StandardsSelector'
|
||||
|
||||
interface StandardsConfig {
|
||||
sheet: string // Лист Excel
|
||||
startCell: string // Первая ячейка (например "A10")
|
||||
maxItems: number // Обычно 7
|
||||
targetCells: CellTarget[]
|
||||
selectedStandards?: string[] // Выбранные эталоны (ID)
|
||||
}
|
||||
import { StandardsConfig } from './definition'
|
||||
|
||||
interface StandardsEditorProps {
|
||||
config: StandardsConfig
|
||||
@@ -32,8 +24,6 @@ export const StandardsEditor: React.FC<StandardsEditorProps> = ({
|
||||
|
||||
// Обеспечиваем значения по умолчанию
|
||||
const safeConfig = {
|
||||
sheet: config.sheet || 'L',
|
||||
startCell: config.startCell || 'A1',
|
||||
maxItems: config.maxItems || 7,
|
||||
targetCells: config.targetCells || [],
|
||||
selectedStandards: config.selectedStandards || [],
|
||||
@@ -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,188 @@
|
||||
import { Badge } from '@/component/ui/badge'
|
||||
import { Button } from '@/component/ui/button'
|
||||
import { Label } from '@/component/ui/label'
|
||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||
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,
|
||||
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)
|
||||
|
||||
// Автоматическая инициализация onChange при загрузке данных эталонов
|
||||
useEffect(() => {
|
||||
if (standards.length > 0 && selectedStandardIds.length > 0) {
|
||||
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)
|
||||
|
||||
onChange?.({
|
||||
standardIds: selectedStandardIds,
|
||||
standardNames,
|
||||
})
|
||||
}
|
||||
}, [selectedStandardIds, standards, onChange])
|
||||
|
||||
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"
|
||||
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] || '',
|
||||
}))
|
||||
},
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { textDefinition } from '@/component/BasicElements'
|
||||
import { calibrationConditionsDefinition } from '@/component/BasicElements/CalibrationConditionsElement'
|
||||
import { numberDefinition } from '@/component/BasicElements/NumberElement'
|
||||
import { selectDefinition } from '@/component/BasicElements/SelectElement'
|
||||
import { standardsDefinition } from '@/component/StandardsElement/definition'
|
||||
import { verificationConditionsDefinition } from '@/component/VerificationConditionsElement/definition'
|
||||
import { verificationConditionsElementDefinition as verificationConditionsDefinition } from '@/component/VerificationConditionsElement/definition'
|
||||
import { buttonGroupDefinition } from '@/entitiy/element/model/implementations/ButtonGroup'
|
||||
import { dateDefinition } from '@/entitiy/element/model/implementations/DateElement'
|
||||
import { standardsDefinition } from '@/entitiy/element/model/implementations/StandardsElement/definition'
|
||||
import { CellTarget } from '@/type/template'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
@@ -67,7 +66,6 @@ export const elementDefinitions = {
|
||||
number: numberDefinition,
|
||||
date: dateDefinition,
|
||||
standards: standardsDefinition,
|
||||
calibration_conditions: calibrationConditionsDefinition,
|
||||
verification_conditions: verificationConditionsDefinition,
|
||||
button_group: buttonGroupDefinition,
|
||||
} as const
|
||||
@@ -80,7 +78,6 @@ export function initializeElementRegistry() {
|
||||
registerElement('number', numberDefinition)
|
||||
registerElement('date', dateDefinition)
|
||||
registerElement('button_group', buttonGroupDefinition)
|
||||
registerElement('calibration_conditions', calibrationConditionsDefinition)
|
||||
registerElement('standards', standardsDefinition)
|
||||
registerElement('verification_conditions', verificationConditionsDefinition)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
import {
|
||||
useCreateStandard,
|
||||
useDeleteStandard,
|
||||
useStandards,
|
||||
useUpdateStandard,
|
||||
} from '@/component/StandardsElement/hooks'
|
||||
import {
|
||||
CreateStandardInput,
|
||||
PatchStandardInput,
|
||||
Standard,
|
||||
} from '@/component/StandardsElement/types'
|
||||
import { Badge } from '@/component/ui/badge'
|
||||
import { Button } from '@/component/ui/button'
|
||||
import {
|
||||
@@ -19,6 +8,17 @@ import {
|
||||
} from '@/component/ui/dialog'
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { Label } from '@/component/ui/label'
|
||||
import {
|
||||
useCreateStandard,
|
||||
useDeleteStandard,
|
||||
useStandards,
|
||||
useUpdateStandard,
|
||||
} from '@/entitiy/element/model/implementations/StandardsElement/hooks'
|
||||
import {
|
||||
CreateStandardInput,
|
||||
PatchStandardInput,
|
||||
Standard,
|
||||
} from '@/entitiy/element/model/implementations/StandardsElement/types'
|
||||
import {
|
||||
Calendar,
|
||||
Edit,
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ['class'],
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
darkMode: ['class'],
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
}
|
||||
}
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
chart: {
|
||||
1: 'hsl(var(--chart-1))',
|
||||
2: 'hsl(var(--chart-2))',
|
||||
3: 'hsl(var(--chart-3))',
|
||||
4: 'hsl(var(--chart-4))',
|
||||
5: 'hsl(var(--chart-5))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
}
|
||||
|
||||
24
yarn.lock
24
yarn.lock
@@ -12,6 +12,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6"
|
||||
integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==
|
||||
|
||||
"@date-fns/tz@1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@date-fns/tz/-/tz-1.2.0.tgz#81cb3211693830babaf3b96aff51607e143030a6"
|
||||
integrity sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==
|
||||
|
||||
"@dnd-kit/accessibility@^3.1.1":
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz#3b4202bd6bb370a0730f6734867785919beac6af"
|
||||
@@ -1639,6 +1644,16 @@ data-view-byte-offset@^1.0.1:
|
||||
es-errors "^1.3.0"
|
||||
is-data-view "^1.0.1"
|
||||
|
||||
date-fns-jalali@4.1.0-0:
|
||||
version "4.1.0-0"
|
||||
resolved "https://registry.yarnpkg.com/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz#9c7fb286004fab267a300d3e9f1ada9f10b4b6b0"
|
||||
integrity sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==
|
||||
|
||||
date-fns@4.1.0, date-fns@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14"
|
||||
integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==
|
||||
|
||||
debug@^3.2.7:
|
||||
version "3.2.7"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
|
||||
@@ -3207,6 +3222,15 @@ react-beautiful-dnd@^13.1.1:
|
||||
redux "^4.0.4"
|
||||
use-memo-one "^1.1.1"
|
||||
|
||||
react-day-picker@^9.8.0:
|
||||
version "9.8.0"
|
||||
resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-9.8.0.tgz#b16367fb26f3a967154e0e0f141297bd1f0a6e6b"
|
||||
integrity sha512-E0yhhg7R+pdgbl/2toTb0xBhsEAtmAx1l7qjIWYfcxOy8w4rTSVfbtBoSzVVhPwKP/5E9iL38LivzoE3AQDhCQ==
|
||||
dependencies:
|
||||
"@date-fns/tz" "1.2.0"
|
||||
date-fns "4.1.0"
|
||||
date-fns-jalali "4.1.0-0"
|
||||
|
||||
react-dom@^18.2.0:
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"
|
||||
|
||||
Reference in New Issue
Block a user