переименования папок
This commit is contained in:
642
src/component/BasicElements/CalibrationConditionsElement.tsx
Normal file
642
src/component/BasicElements/CalibrationConditionsElement.tsx
Normal file
@@ -0,0 +1,642 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ElementDefinition } from '@/lib/element-registry'
|
||||
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
|
||||
}
|
||||
|
||||
export const calibrationConditionsDefinition: ElementDefinition<
|
||||
CalibrationConditionsConfig,
|
||||
CalibrationConditions
|
||||
> = {
|
||||
type: 'calibration-conditions',
|
||||
label: 'Условия калибровки',
|
||||
icon: <Thermometer className="h-4 w-4" />,
|
||||
defaultConfig: {
|
||||
targetCells: [],
|
||||
defaultConditions: {
|
||||
temperature: '20',
|
||||
humidity: '65',
|
||||
pressure: '101.3',
|
||||
voltage: '220',
|
||||
frequency: '50',
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
mapToCells: () => {
|
||||
// Генерируем ячейки для каждого параметра калибровки
|
||||
const cells = [
|
||||
{ sheet: 'L', cell: 'B2', displayName: 'Температура' },
|
||||
{ sheet: 'L', cell: 'B3', displayName: 'Влажность' },
|
||||
{ sheet: 'L', cell: 'B4', displayName: 'Давление' },
|
||||
{ sheet: 'L', cell: 'B5', displayName: 'Напряжение' },
|
||||
{ sheet: 'L', cell: 'B6', displayName: 'Частота' },
|
||||
{ sheet: 'L', cell: 'B7', displayName: 'Дата обновления' },
|
||||
]
|
||||
return cells
|
||||
},
|
||||
Editor: ({ config, onChange }) => (
|
||||
<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>
|
||||
<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 }) => {
|
||||
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
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user