Files
protoc-frontend/src/component/BasicElements/CalibrationConditionsElement.tsx
2025-07-23 16:55:35 +03:00

781 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
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 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>
<Button
variant="ghost"
size="icon"
onClick={openModal}
className={`h-6 w-6 p-0 ${
isOutdated ? 'text-yellow-600 hover:text-yellow-700' : ''
}`}
>
<Edit3 className="h-3 w-3" />
</Button>
{isOutdated && (
<div className="h-2 w-2 animate-pulse rounded-full bg-yellow-500" />
)}
</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">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<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>
</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
}}
/>
)
},
}