почти рабочий вариант
This commit is contained in:
@@ -34,6 +34,7 @@
|
|||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"eslint-import-resolver-typescript": "^4.4.4",
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
|
"react-day-picker": "^9.8.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-grid-layout": "^1.5.2",
|
"react-grid-layout": "^1.5.2",
|
||||||
"react-rnd": "^10.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 { textDefinition } from '@/entitiy/element/model/implementations/TextElement'
|
||||||
export { buttonGroupDefinition } from '../../entitiy/element/model/implementations/ButtonGroup'
|
export { buttonGroupDefinition } from '../../entitiy/element/model/implementations/ButtonGroup'
|
||||||
export { dateDefinition } from '../../entitiy/element/model/implementations/DateElement'
|
export { dateDefinition } from '../../entitiy/element/model/implementations/DateElement'
|
||||||
export { calibrationConditionsDefinition } from './CalibrationConditionsElement'
|
|
||||||
export { numberDefinition } from './NumberElement'
|
export { numberDefinition } from './NumberElement'
|
||||||
export { selectDefinition } from './SelectElement'
|
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
|
<Input
|
||||||
placeholder="A1"
|
placeholder="A12"
|
||||||
value={target.cell}
|
value={target.cell}
|
||||||
onChange={e => handleCellInputChange(index, e.target.value)}
|
onChange={e => handleCellInputChange(index, e.target.value)}
|
||||||
className="h-6 w-16 px-2 text-xs"
|
className="h-6 w-16 px-2 text-xs"
|
||||||
@@ -231,36 +231,6 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
|||||||
? getElementDefinition(formData.type)
|
? getElementDefinition(formData.type)
|
||||||
: null
|
: 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 (
|
return (
|
||||||
<div className="grid h-full grid-cols-1 gap-6 lg:grid-cols-2">
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Предпросмотр */}
|
{/* Предпросмотр */}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Badge } from '@/component/ui/badge'
|
import { Badge } from '@/component/ui/badge'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
|
|
||||||
import { Label } from '@/component/ui/label'
|
import { Label } from '@/component/ui/label'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -8,13 +7,13 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/component/ui/select'
|
} from '@/component/ui/select'
|
||||||
import { Building2, Target } from 'lucide-react'
|
import { CellTarget } from '@/type/template'
|
||||||
import { useLaboratories } from './hooks'
|
import { Building2 } from 'lucide-react'
|
||||||
import { mockApiHelpers } from './mockData'
|
import { useLaboratories, useLaboratoryConditions } from './hooks'
|
||||||
import { ConditionCellMapping, VerificationConditionsConfig } from './types'
|
import { ConditionMapping, VerificationConditionsConfig } from './types'
|
||||||
|
|
||||||
interface VerificationConditionsEditorProps {
|
interface VerificationConditionsEditorProps {
|
||||||
config: VerificationConditionsConfig
|
config: VerificationConditionsConfig & { targetCells?: CellTarget[] }
|
||||||
onChange: (config: VerificationConditionsConfig) => void
|
onChange: (config: VerificationConditionsConfig) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,204 +22,198 @@ export function VerificationConditionsEditor({
|
|||||||
onChange,
|
onChange,
|
||||||
}: VerificationConditionsEditorProps) {
|
}: VerificationConditionsEditorProps) {
|
||||||
const { data: laboratories = [], isLoading } = useLaboratories()
|
const { data: laboratories = [], isLoading } = useLaboratories()
|
||||||
|
const { data: labData } = useLaboratoryConditions(config.selectedLaboratoryId)
|
||||||
|
|
||||||
const updateConfig = (updates: Partial<VerificationConditionsConfig>) => {
|
const updateConfig = (updates: Partial<VerificationConditionsConfig>) => {
|
||||||
onChange({ ...config, ...updates })
|
onChange({ ...config, ...updates })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем выбранную лабораторию
|
// Получить доступные целевые ячейки из конфигурации
|
||||||
const selectedLab = laboratories.find(
|
const getAvailableTargetCells = () => {
|
||||||
lab => lab.id === config.selectedLaboratoryId
|
return config.targetCells || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновить маппинг для конкретного условия
|
||||||
|
const updateConditionMapping = (
|
||||||
|
conditionId: string,
|
||||||
|
conditionName: string,
|
||||||
|
targetCellKey?: string // формат: "sheet:cell", например "L:A1"
|
||||||
|
) => {
|
||||||
|
const currentMappings = config.conditionMappings || []
|
||||||
|
let updatedMappings = [...currentMappings]
|
||||||
|
|
||||||
|
// Удаляем существующий маппинг для этого условия
|
||||||
|
updatedMappings = updatedMappings.filter(
|
||||||
|
mapping => mapping.conditionKey !== conditionId
|
||||||
)
|
)
|
||||||
|
|
||||||
// Получаем все доступные условия (либо все из БД, либо условия конкретной лаборатории)
|
// Если выбрана ячейка, добавляем новый маппинг
|
||||||
const availableConditions = config.selectedLaboratoryId
|
if (targetCellKey) {
|
||||||
? mockApiHelpers.getLaboratoryConditions(config.selectedLaboratoryId)
|
const [sheet, cell] = targetCellKey.split(':')
|
||||||
: mockApiHelpers.getAllConditions()
|
const targetCells = getAvailableTargetCells()
|
||||||
|
const targetIndex = targetCells.findIndex(
|
||||||
// Обновление привязки условия к ячейке
|
tc => tc.sheet === sheet && tc.cell === cell
|
||||||
const updateConditionMapping = (cellIndex: number, conditionKey: string) => {
|
|
||||||
const conditionName =
|
|
||||||
availableConditions.find(c => c.key === conditionKey)?.name ||
|
|
||||||
conditionKey
|
|
||||||
|
|
||||||
const newMappings = [...(config.conditionMappings || [])]
|
|
||||||
const existingIndex = newMappings.findIndex(m => m.cellIndex === cellIndex)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateConfig({ conditionMappings: newMappings })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получить выбранное условие для ячейки
|
|
||||||
const getSelectedCondition = (cellIndex: number): string => {
|
|
||||||
const mapping = config.conditionMappings?.find(
|
|
||||||
m => m.cellIndex === cellIndex
|
|
||||||
)
|
)
|
||||||
return mapping?.conditionKey || '__none__'
|
|
||||||
|
if (targetIndex !== -1) {
|
||||||
|
const newMapping: ConditionMapping = {
|
||||||
|
conditionKey: conditionId,
|
||||||
|
conditionName: conditionName,
|
||||||
|
cellIndex: targetIndex, // используем индекс в массиве targetCells
|
||||||
}
|
}
|
||||||
|
updatedMappings.push(newMapping)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfig({ conditionMappings: updatedMappings })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить выбранную ячейку для условия
|
||||||
|
const getSelectedTargetCell = (conditionId: string): string | undefined => {
|
||||||
|
const mapping = (config.conditionMappings || []).find(
|
||||||
|
mapping => mapping.conditionKey === conditionId
|
||||||
|
)
|
||||||
|
if (!mapping) return undefined
|
||||||
|
|
||||||
|
const targetCells = getAvailableTargetCells()
|
||||||
|
const targetCell = targetCells[mapping.cellIndex]
|
||||||
|
return targetCell ? `${targetCell.sheet}:${targetCell.cell}` : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить занятые ячейки (исключая текущее условие)
|
||||||
|
const getOccupiedTargetCells = (excludeConditionId?: string): string[] => {
|
||||||
|
const targetCells = getAvailableTargetCells()
|
||||||
|
return (config.conditionMappings || [])
|
||||||
|
.filter(mapping => mapping.conditionKey !== excludeConditionId)
|
||||||
|
.map(mapping => {
|
||||||
|
const targetCell = targetCells[mapping.cellIndex]
|
||||||
|
return targetCell ? `${targetCell.sheet}:${targetCell.cell}` : ''
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCells = getAvailableTargetCells()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Выбор лаборатории */}
|
{/* Компактный выбор лаборатории */}
|
||||||
<Card>
|
<div className="flex items-center gap-3">
|
||||||
<CardHeader>
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<Building2 className="h-4 w-4 text-blue-600" />
|
||||||
<Building2 className="h-4 w-4" />
|
<Label className="text-sm font-medium">Лаборатория</Label>
|
||||||
Лаборатория
|
</div>
|
||||||
</CardTitle>
|
<div className="flex-1">
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="laboratory">Выберите лабораторию</Label>
|
|
||||||
<Select
|
<Select
|
||||||
value={config.selectedLaboratoryId?.toString() || '__none__'}
|
value={config.selectedLaboratoryId || ''}
|
||||||
onValueChange={value => {
|
onValueChange={value =>
|
||||||
const laboratoryId =
|
|
||||||
value === '__none__' ? undefined : parseInt(value)
|
|
||||||
updateConfig({
|
updateConfig({
|
||||||
selectedLaboratoryId: laboratoryId,
|
selectedLaboratoryId: value || undefined,
|
||||||
conditionMappings: [], // Сбрасываем привязки при смене лаборатории
|
|
||||||
})
|
})
|
||||||
}}
|
}
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="h-8 text-sm">
|
||||||
<SelectValue placeholder="Выберите лабораторию" />
|
<SelectValue
|
||||||
|
placeholder={isLoading ? 'Загрузка...' : 'Выберите лабораторию'}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__">Все условия из БД</SelectItem>
|
|
||||||
{laboratories.map(lab => (
|
{laboratories.map(lab => (
|
||||||
<SelectItem key={lab.id} value={lab.id.toString()}>
|
<SelectItem key={lab.id} value={lab.id}>
|
||||||
{lab.name}
|
{lab.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{config.selectedLaboratoryId
|
|
||||||
? 'Доступны только условия выбранной лаборатории'
|
|
||||||
: 'Доступны все условия из базы данных'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Привязка условий к ячейкам */}
|
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
|
{/* Маппинг условий */}
|
||||||
|
{labData && targetCells.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{config.targetCells.map((cell, index) => (
|
<Label className="text-sm font-medium text-gray-700">
|
||||||
<div
|
Маппинг условий поверки
|
||||||
key={index}
|
|
||||||
className="flex items-center gap-3 rounded-md border p-3"
|
|
||||||
>
|
|
||||||
<Badge variant="outline" className="shrink-0">
|
|
||||||
{cell.sheet}!{cell.cell}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label className="text-xs text-muted-foreground">
|
|
||||||
Ячейка {index + 1} - выберите условие:
|
|
||||||
</Label>
|
</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{labData.conditionTypes.map(conditionType => {
|
||||||
|
const selectedCell = getSelectedTargetCell(conditionType.id)
|
||||||
|
const occupiedCells = getOccupiedTargetCells(conditionType.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{/* Информация об условии */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Компактный селектор ячейки */}
|
||||||
<Select
|
<Select
|
||||||
value={getSelectedCondition(index)}
|
value={selectedCell || 'UNSELECTED'}
|
||||||
onValueChange={value =>
|
onValueChange={value =>
|
||||||
updateConditionMapping(index, value)
|
updateConditionMapping(
|
||||||
|
conditionType.id,
|
||||||
|
conditionType.name,
|
||||||
|
value === 'UNSELECTED' ? undefined : value
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1">
|
<SelectTrigger className="h-8 w-20 text-xs">
|
||||||
<SelectValue placeholder="Выберите условие" />
|
<SelectValue placeholder="—" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__">Не выбрано</SelectItem>
|
<SelectItem value="UNSELECTED">—</SelectItem>
|
||||||
{availableConditions.map(condition => (
|
{targetCells
|
||||||
<SelectItem key={condition.key} value={condition.key}>
|
.map(
|
||||||
<div className="flex items-center gap-2">
|
(target, index) => `${target.sheet}:${target.cell}`
|
||||||
<span>{condition.name}</span>
|
)
|
||||||
{condition.unit && (
|
.filter(cellKey => !occupiedCells.includes(cellKey))
|
||||||
<Badge variant="secondary" className="text-xs">
|
.map(cellKey => (
|
||||||
{condition.unit}
|
<SelectItem key={cellKey} value={cellKey}>
|
||||||
</Badge>
|
{cellKey}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Подсказка с доступными условиями */}
|
{labData.conditionTypes.length === 0 && (
|
||||||
<div className="rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
|
<div className="rounded-md border border-dashed border-gray-300 py-4 text-center">
|
||||||
<p className="font-medium">
|
<p className="text-xs text-gray-500">
|
||||||
Доступные условия{' '}
|
У выбранной лаборатории нет настроенных типов условий
|
||||||
{selectedLab ? `для ${selectedLab.name}` : 'из базы данных'}:
|
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Подсказка если нет целевых ячеек */}
|
{/* Предупреждение если нет целевых ячеек */}
|
||||||
{config.targetCells.length === 0 && (
|
{targetCells.length === 0 && (
|
||||||
<Card>
|
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2">
|
||||||
<CardContent className="pt-6">
|
<p className="text-xs text-amber-700">
|
||||||
<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>
|
||||||
<p className="mt-1">
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</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 { 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'
|
import { VerificationConditionsConfig } from './types'
|
||||||
|
|
||||||
interface VerificationConditionsPreviewProps {
|
interface VerificationConditionsPreviewProps {
|
||||||
config: VerificationConditionsConfig
|
config: VerificationConditionsConfig
|
||||||
}
|
onChange?: (config: VerificationConditionsConfig) => void
|
||||||
|
|
||||||
// Моковые данные для предварительного просмотра
|
|
||||||
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" />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VerificationConditionsPreview({
|
export function VerificationConditionsPreview({
|
||||||
config,
|
config,
|
||||||
|
onChange,
|
||||||
}: VerificationConditionsPreviewProps) {
|
}: 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 (
|
return (
|
||||||
<Card className="w-full max-w-md">
|
<Card className="border-dashed">
|
||||||
<CardHeader className="pb-3">
|
<CardContent className="p-3">
|
||||||
<CardTitle className="text-sm">Условия поверки</CardTitle>
|
<div className="text-center text-muted-foreground">
|
||||||
</CardHeader>
|
<Building2 className="mx-auto mb-1 h-4 w-4 opacity-50" />
|
||||||
<CardContent className="space-y-4">
|
<p className="text-xs">Лаборатория не выбрана</p>
|
||||||
{/* Селекторы лаборатории и даты */}
|
</div>
|
||||||
<div className="space-y-3">
|
</CardContent>
|
||||||
<div>
|
</Card>
|
||||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
)
|
||||||
<Building2 className="h-3 w-3" />
|
}
|
||||||
Лаборатория
|
|
||||||
</div>
|
if (!labData) {
|
||||||
<div className="rounded-md border bg-muted/30 p-2 text-sm">
|
return (
|
||||||
{config.selectedLaboratoryId
|
<Card>
|
||||||
? mockData.laboratory.name
|
<CardContent className="p-3">
|
||||||
: 'Не выбрана'}
|
<div className="text-center text-muted-foreground">
|
||||||
</div>
|
<div className="mx-auto mb-1 h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
</div>
|
<p className="text-xs">Загрузка...</p>
|
||||||
|
</div>
|
||||||
<div>
|
</CardContent>
|
||||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
</Card>
|
||||||
<Calendar className="h-3 w-3" />
|
)
|
||||||
Дата
|
}
|
||||||
</div>
|
|
||||||
<div className="rounded-md border bg-muted/30 p-2 text-sm">
|
return (
|
||||||
{config.selectedDate || mockData.date}
|
<Card>
|
||||||
</div>
|
<CardHeader className="px-3 pb-1 pt-2">
|
||||||
</div>
|
<CardTitle className="flex items-center gap-1.5 text-sm">
|
||||||
</div>
|
<Building2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="truncate">{selectedLab?.name || 'Лаборатория'}</span>
|
||||||
{/* Условия */}
|
</CardTitle>
|
||||||
<div>
|
</CardHeader>
|
||||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
|
||||||
Условия
|
<CardContent className="space-y-2 px-3 pb-3">
|
||||||
</div>
|
{/* Выбор даты */}
|
||||||
<div className="space-y-2">
|
<div className="flex items-center gap-2">
|
||||||
{displayedConditions.map((condition, index) => (
|
<CalendarIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<div
|
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
|
||||||
key={index}
|
<PopoverTrigger asChild>
|
||||||
className="flex items-center justify-between rounded-md border bg-background p-2 text-sm"
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
<div className="flex items-center gap-2">
|
className="h-6 justify-start border-none bg-muted/50 px-2 text-xs font-normal hover:bg-muted/70"
|
||||||
{condition.icon}
|
disabled={!onChange}
|
||||||
<span className="font-medium">{condition.typeName}</span>
|
>
|
||||||
</div>
|
{new Date(selectedDate).toLocaleDateString('ru-RU')}
|
||||||
<div className="flex items-center gap-1">
|
</Button>
|
||||||
<span>{condition.value}</span>
|
</PopoverTrigger>
|
||||||
{config.showUnits && condition.unit && (
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Calendar
|
||||||
{condition.unit}
|
mode="single"
|
||||||
</Badge>
|
selected={new Date(selectedDate)}
|
||||||
)}
|
onSelect={handleCalendarSelect}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</PopoverContent>
|
||||||
))}
|
</Popover>
|
||||||
|
</div>
|
||||||
{mockData.conditions.length > config.maxConditions && (
|
|
||||||
<div className="text-center text-xs text-muted-foreground">
|
{/* Статус и данные */}
|
||||||
... и ещё {mockData.conditions.length - config.maxConditions}{' '}
|
<div className="border-t pt-2">
|
||||||
условий
|
{isLoading ? (
|
||||||
</div>
|
<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" />
|
||||||
</div>
|
<span>Загрузка...</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : hasConditions ? (
|
||||||
{/* Настройки */}
|
<div className="space-y-2">
|
||||||
<div className="rounded-md bg-muted/50 p-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-xs text-green-600">
|
||||||
<div className="space-y-1">
|
<CheckCircle className="h-3.5 w-3.5" />
|
||||||
<div>
|
<span className="font-medium">Данные внесены</span>
|
||||||
Лист: <span className="font-mono">{config.sheet}</span>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
Ячейка: <span className="font-mono">{config.startCell}</span>
|
{labData.conditionTypes.map(conditionType => {
|
||||||
</div>
|
const conditionValue =
|
||||||
<div>Макс. условий: {config.maxConditions}</div>
|
dailyCondition!.conditions[conditionType.id]
|
||||||
{config.autoSave && <div>✓ Автосохранение</div>}
|
return (
|
||||||
{config.showUnits && <div>✓ Показывать единицы</div>}
|
<div
|
||||||
{config.targetCells.length > 0 && (
|
key={conditionType.id}
|
||||||
<div>Ячеек настроено: {config.targetCells.length}</div>
|
className="flex items-center justify-between rounded bg-muted/30 px-2 py-1 text-xs"
|
||||||
)}
|
>
|
||||||
</div>
|
<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 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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { Button } from '@/component/ui/button'
|
||||||
|
import { Calendar } from '@/component/ui/calendar'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
|
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 { Input } from '@/component/ui/input'
|
||||||
import { Label } from '@/component/ui/label'
|
import { Label } from '@/component/ui/label'
|
||||||
import {
|
import { Popover, PopoverContent, PopoverTrigger } from '@/component/ui/popover'
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/component/ui/select'
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Building2,
|
Building2,
|
||||||
Calendar,
|
Calendar as CalendarIcon,
|
||||||
CheckCircle2,
|
CheckCircle,
|
||||||
Droplets,
|
Plus,
|
||||||
Gauge,
|
Settings,
|
||||||
Loader2,
|
|
||||||
Save,
|
|
||||||
Thermometer,
|
|
||||||
Zap,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useVerificationConditionsElement } from './hooks'
|
import {
|
||||||
|
useDailyConditions,
|
||||||
|
useLaboratories,
|
||||||
|
useLaboratoryConditions,
|
||||||
|
} from './hooks'
|
||||||
import {
|
import {
|
||||||
VerificationConditionsConfig,
|
VerificationConditionsConfig,
|
||||||
VerificationConditionsValue,
|
VerificationConditionsValue,
|
||||||
@@ -34,277 +34,352 @@ interface VerificationConditionsRenderProps {
|
|||||||
config: VerificationConditionsConfig
|
config: VerificationConditionsConfig
|
||||||
value?: VerificationConditionsValue
|
value?: VerificationConditionsValue
|
||||||
onChange?: (value: VerificationConditionsValue) => void
|
onChange?: (value: VerificationConditionsValue) => void
|
||||||
}
|
onSave?: (value: VerificationConditionsValue) => Promise<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" />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VerificationConditionsRender({
|
export function VerificationConditionsRender({
|
||||||
config,
|
config,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
onSave,
|
||||||
}: VerificationConditionsRenderProps) {
|
}: 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 {
|
const {
|
||||||
laboratories,
|
data: dailyCondition,
|
||||||
selectedLaboratory,
|
|
||||||
selectedLaboratoryId,
|
|
||||||
selectedDate,
|
|
||||||
conditions,
|
|
||||||
localConditions,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
isSaving,
|
|
||||||
hasUnsavedChanges,
|
|
||||||
validationErrors,
|
|
||||||
changeLaboratory,
|
|
||||||
changeDate,
|
|
||||||
updateCondition,
|
|
||||||
saveConditions,
|
saveConditions,
|
||||||
getCurrentValue,
|
isSaving,
|
||||||
formatValueWithUnit,
|
} = useDailyConditions(config.selectedLaboratoryId, selectedDate)
|
||||||
getConditionKey,
|
|
||||||
} = useVerificationConditionsElement({
|
|
||||||
selectedLaboratoryId: config.selectedLaboratoryId || value?.laboratoryId,
|
|
||||||
selectedDate: config.selectedDate || value?.date,
|
|
||||||
autoSave: config.autoSave,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Синхронизируем значение с родительским компонентом
|
const selectedLab = laboratories.find(
|
||||||
|
lab => lab.id === config.selectedLaboratoryId
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasConditions =
|
||||||
|
dailyCondition && Object.keys(dailyCondition.conditions).length > 0
|
||||||
|
|
||||||
|
// Автоматическая инициализация onChange при загрузке данных
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onChange) {
|
if (dailyCondition && onChange && hasConditions) {
|
||||||
const currentValue = getCurrentValue()
|
onChange({
|
||||||
if (
|
date: selectedDate,
|
||||||
currentValue &&
|
conditions: dailyCondition.conditions,
|
||||||
(!value ||
|
})
|
||||||
currentValue.laboratoryId !== value.laboratoryId ||
|
|
||||||
currentValue.date !== value.date ||
|
|
||||||
JSON.stringify(currentValue.conditions) !==
|
|
||||||
JSON.stringify(value.conditions))
|
|
||||||
) {
|
|
||||||
onChange(currentValue)
|
|
||||||
}
|
}
|
||||||
}
|
}, [dailyCondition, onChange, selectedDate, hasConditions])
|
||||||
}, [onChange, getCurrentValue, value])
|
|
||||||
|
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 {
|
try {
|
||||||
await saveConditions()
|
await saveConditions(editingConditions)
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
onChange({
|
||||||
|
date: selectedDate,
|
||||||
|
conditions: editingConditions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditDialogOpen(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка сохранения:', error)
|
console.error('Ошибка сохранения:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение ошибки валидации для поля
|
const handleConditionChange = (conditionId: string, value: string) => {
|
||||||
const getFieldError = (fieldKey: string) => {
|
setEditingConditions(prev => ({
|
||||||
return validationErrors.find(error => error.field === fieldKey)
|
...prev,
|
||||||
|
[conditionId]: value,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (!config.selectedLaboratoryId) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="border-dashed">
|
||||||
<CardContent className="flex items-center justify-center p-6">
|
<CardContent className="p-3">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (!labData) {
|
||||||
return (
|
return (
|
||||||
<Alert variant="destructive">
|
<Card>
|
||||||
<AlertCircle className="h-4 w-4" />
|
<CardContent className="p-3">
|
||||||
<AlertDescription>
|
<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" />
|
||||||
</AlertDescription>
|
<p className="text-xs">Загрузка...</p>
|
||||||
</Alert>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full">
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="px-3 pb-1 pt-2">
|
||||||
<CardTitle className="flex items-center justify-between text-sm">
|
<CardTitle className="flex items-center gap-1.5 text-sm">
|
||||||
<span>Условия поверки</span>
|
<Building2 className="h-3.5 w-3.5" />
|
||||||
{hasUnsavedChanges && !config.autoSave && (
|
<span className="truncate">{selectedLab?.name || 'Лаборатория'}</span>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* Селекторы лаборатории и даты */}
|
<CardContent className="space-y-2 px-3 pb-3">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
{/* Выбор даты */}
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<Label className="flex items-center gap-2 text-xs font-medium">
|
<CalendarIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<Building2 className="h-3 w-3" />
|
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
|
||||||
Лаборатория
|
<PopoverTrigger asChild>
|
||||||
</Label>
|
<Button
|
||||||
<Select
|
variant="outline"
|
||||||
value={selectedLaboratoryId?.toString() || ''}
|
className="h-6 justify-start border-none bg-muted/50 px-2 text-xs font-normal hover:bg-muted/70"
|
||||||
onValueChange={value => changeLaboratory(parseInt(value))}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1">
|
{new Date(selectedDate).toLocaleDateString('ru-RU')}
|
||||||
<SelectValue placeholder="Выберите лабораторию" />
|
</Button>
|
||||||
</SelectTrigger>
|
</PopoverTrigger>
|
||||||
<SelectContent>
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
{laboratories.map(lab => (
|
<Calendar
|
||||||
<SelectItem key={lab.id} value={lab.id.toString()}>
|
mode="single"
|
||||||
<div>
|
selected={new Date(selectedDate)}
|
||||||
<div className="font-medium">{lab.name}</div>
|
onSelect={handleCalendarSelect}
|
||||||
<div className="text-xs text-muted-foreground">
|
className="rounded-md border shadow-sm"
|
||||||
{lab.code}
|
captionLayout="dropdown"
|
||||||
</div>
|
/>
|
||||||
</div>
|
</PopoverContent>
|
||||||
</SelectItem>
|
</Popover>
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Статус и данные */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => handleOpenEditDialog(false)}
|
||||||
|
>
|
||||||
|
<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>
|
<div>
|
||||||
<Label className="flex items-center gap-2 text-xs font-medium">
|
{new Date(selectedDate).toLocaleDateString('ru-RU')}
|
||||||
<Calendar className="h-3 w-3" />
|
</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>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
value={editingConditions[conditionType.id] || ''}
|
||||||
value={selectedDate || ''}
|
onChange={e =>
|
||||||
onChange={e => changeDate(e.target.value)}
|
handleConditionChange(
|
||||||
className="mt-1"
|
conditionType.id,
|
||||||
|
e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Значение"
|
||||||
|
className="h-7 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Условия */}
|
<div className="flex gap-2">
|
||||||
{conditions && selectedLaboratoryId && selectedDate && (
|
<Button
|
||||||
<div>
|
onClick={handleSaveConditions}
|
||||||
<Label className="text-xs font-medium text-muted-foreground">
|
disabled={isSaving}
|
||||||
Условия поверки
|
size="sm"
|
||||||
</Label>
|
className="h-7 flex-1 text-xs"
|
||||||
<div className="mt-2 space-y-2">
|
>
|
||||||
{conditions.conditions
|
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
||||||
.slice(0, config.maxConditions)
|
</Button>
|
||||||
.map((condition, index) => {
|
<Button
|
||||||
const key = getConditionKey(condition.typeName)
|
variant="outline"
|
||||||
const error = getFieldError(key)
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={condition.typeId}
|
key={conditionType.id}
|
||||||
className="flex items-center gap-3 rounded-md border bg-background p-3"
|
className="flex items-center justify-between rounded bg-muted/30 px-2 py-1 text-xs"
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<span className="truncate font-medium">
|
||||||
{getConditionIcon(condition.typeName)}
|
{conditionType.name}
|
||||||
<div className="min-w-0 flex-1">
|
</span>
|
||||||
<div className="truncate text-sm font-medium">
|
<span className="ml-2 font-mono text-muted-foreground">
|
||||||
{condition.typeName}
|
{conditionValue || '—'}
|
||||||
</div>
|
{conditionValue && conditionType.unit && (
|
||||||
{config.showUnits && condition.unit && (
|
<span className="ml-1">{conditionType.unit}</span>
|
||||||
<Badge variant="secondary" className="mt-1 text-xs">
|
|
||||||
{condition.unit}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</span>
|
||||||
</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>
|
|
||||||
</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>
|
||||||
|
|
||||||
{conditions.conditions.length > config.maxConditions && (
|
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||||
<div className="text-center text-xs text-muted-foreground">
|
<DialogTrigger asChild>
|
||||||
... и ещё{' '}
|
<Button
|
||||||
{conditions.conditions.length - config.maxConditions} условий
|
onClick={() => handleOpenEditDialog(true)}
|
||||||
</div>
|
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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Ошибки валидации */}
|
<div className="space-y-2">
|
||||||
{validationErrors.length > 0 && (
|
{labData.conditionTypes.map(conditionType => (
|
||||||
<Alert variant="destructive">
|
<div key={conditionType.id} className="space-y-1">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<Label className="text-xs font-medium">
|
||||||
<AlertDescription>
|
{conditionType.name}
|
||||||
<div className="space-y-1">
|
{conditionType.unit && (
|
||||||
{validationErrors.map((error, index) => (
|
<span className="ml-1 font-normal text-muted-foreground">
|
||||||
<div key={index} className="text-sm">
|
({conditionType.unit})
|
||||||
{error.message}
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={editingConditions[conditionType.id] || ''}
|
||||||
|
onChange={e =>
|
||||||
|
handleConditionChange(
|
||||||
|
conditionType.id,
|
||||||
|
e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Значение"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Статус */}
|
<div className="flex gap-2">
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<Button
|
||||||
<div className="flex items-center gap-2">
|
onClick={handleSaveConditions}
|
||||||
{config.autoSave && hasUnsavedChanges && (
|
disabled={isSaving}
|
||||||
<div className="flex items-center gap-1">
|
size="sm"
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
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>
|
||||||
{config.autoSave &&
|
</DialogContent>
|
||||||
!hasUnsavedChanges &&
|
</Dialog>
|
||||||
selectedLaboratoryId &&
|
|
||||||
selectedDate && (
|
|
||||||
<div className="flex items-center gap-1 text-green-600">
|
|
||||||
<CheckCircle2 className="h-3 w-3" />
|
|
||||||
Сохранено
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedLaboratory && <div>{selectedLaboratory.name}</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,165 +1,129 @@
|
|||||||
import {
|
import {
|
||||||
ConditionType,
|
ConditionType,
|
||||||
DailyConditions,
|
CreateDailyConditionInput,
|
||||||
ElementConditionsData,
|
CreateDailyConditionOutput,
|
||||||
|
DailyCondition,
|
||||||
|
DailyConditionsFilters,
|
||||||
|
GetDailyConditionOutput,
|
||||||
|
GetDailyConditionsOutput,
|
||||||
|
GetLaboratoriesOutput,
|
||||||
|
GetLaboratoryOutput,
|
||||||
Laboratory,
|
Laboratory,
|
||||||
SaveConditionsResponse,
|
UpdateDailyConditionInput,
|
||||||
VerificationConditionsValue,
|
UpdateDailyConditionOutput,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const BASE_URL = '/api/v1/verification-conditions'
|
// Базовый URL для API (может быть настроен через переменные окружения)
|
||||||
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||||
|
|
||||||
// API для лабораторий
|
// API для лабораторий
|
||||||
export const laboratoriesApi = {
|
export const laboratoriesApi = {
|
||||||
// Получить все лаборатории
|
// Получить все лаборатории
|
||||||
getAll: async (): Promise<Laboratory[]> => {
|
getAll: async (): Promise<Laboratory[]> => {
|
||||||
const response = await fetch(`${BASE_URL}/laboratories`)
|
const response = await fetch(`${BASE_URL}/laboratories/`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка загрузки лабораторий')
|
throw new Error('Ошибка загрузки лабораторий')
|
||||||
}
|
}
|
||||||
return response.json()
|
const data: GetLaboratoriesOutput = await response.json()
|
||||||
|
return data.laboratories
|
||||||
},
|
},
|
||||||
|
|
||||||
// Получить лабораторию по ID
|
// Получить лабораторию по ID
|
||||||
getById: async (id: number): Promise<Laboratory> => {
|
getById: async (id: string): Promise<Laboratory | null> => {
|
||||||
const response = await fetch(`${BASE_URL}/laboratories/${id}`)
|
const response = await fetch(`${BASE_URL}/laboratories/${id}`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Лаборатория не найдена')
|
if (response.status === 404) {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
return response.json()
|
throw new Error('Ошибка получения лаборатории')
|
||||||
},
|
|
||||||
|
|
||||||
// Создать лабораторию
|
|
||||||
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()
|
const data: GetLaboratoryOutput = await response.json()
|
||||||
},
|
return data.laboratory
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// API для ежедневных условий
|
// API для ежедневных условий
|
||||||
export const dailyConditionsApi = {
|
export const dailyConditionsApi = {
|
||||||
// Получить условия по фильтрам
|
// Получить условия по фильтрам
|
||||||
get: async (params: {
|
get: async (
|
||||||
laboratoryId?: number
|
filters: DailyConditionsFilters = {}
|
||||||
date?: string
|
): Promise<DailyCondition[]> => {
|
||||||
dateFrom?: string
|
|
||||||
dateTo?: string
|
|
||||||
}): Promise<DailyConditions[]> => {
|
|
||||||
const searchParams = new URLSearchParams()
|
const searchParams = new URLSearchParams()
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
if (filters.laboratory_id) {
|
||||||
if (value) searchParams.append(key, value.toString())
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка загрузки условий')
|
throw new Error('Ошибка загрузки условий')
|
||||||
}
|
}
|
||||||
return response.json()
|
const data: GetDailyConditionsOutput = await response.json()
|
||||||
|
return data.daily_conditions
|
||||||
},
|
},
|
||||||
|
|
||||||
// Получить условия для элемента
|
// Получить конкретное условие по ID
|
||||||
getForElement: async (
|
getById: async (id: string): Promise<DailyCondition | null> => {
|
||||||
laboratoryId: number,
|
const response = await fetch(`${BASE_URL}/daily-conditions/${id}`)
|
||||||
date: string
|
|
||||||
): Promise<ElementConditionsData> => {
|
|
||||||
const response = await fetch(
|
|
||||||
`${BASE_URL}/daily-conditions/for-element?laboratoryId=${laboratoryId}&date=${date}`
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка загрузки условий для элемента')
|
if (response.status === 404) {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
return response.json()
|
throw new Error('Ошибка получения условия')
|
||||||
|
}
|
||||||
|
const data: GetDailyConditionOutput = await response.json()
|
||||||
|
return data.daily_condition
|
||||||
},
|
},
|
||||||
|
|
||||||
// Сохранить условия из элемента
|
// Создать новые условия
|
||||||
saveForElement: async (
|
create: async (input: CreateDailyConditionInput): Promise<string> => {
|
||||||
data: VerificationConditionsValue
|
const response = await fetch(`${BASE_URL}/daily-conditions/`, {
|
||||||
): Promise<SaveConditionsResponse> => {
|
|
||||||
const response = await fetch(
|
|
||||||
`${BASE_URL}/daily-conditions/save-for-element`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
body: JSON.stringify(data),
|
'Content-Type': 'application/json',
|
||||||
}
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json()
|
|
||||||
throw new Error(error.message || 'Ошибка сохранения условий')
|
|
||||||
}
|
|
||||||
return response.json()
|
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
// Создать/обновить условия
|
|
||||||
save: async (
|
|
||||||
data: Omit<DailyConditions, 'id' | 'createdAt' | 'updatedAt'>
|
|
||||||
): Promise<DailyConditions> => {
|
|
||||||
const response = await fetch(`${BASE_URL}/daily-conditions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json()
|
const error = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ message: 'Ошибка сохранения условий' }))
|
||||||
throw new Error(error.message || 'Ошибка сохранения условий')
|
throw new Error(error.message || 'Ошибка сохранения условий')
|
||||||
}
|
}
|
||||||
return response.json()
|
const data: CreateDailyConditionOutput = await response.json()
|
||||||
|
return data.id
|
||||||
},
|
},
|
||||||
|
|
||||||
// Обновить условия
|
// Обновить условия
|
||||||
update: async (
|
update: async (
|
||||||
id: number,
|
id: string,
|
||||||
data: Partial<DailyConditions>
|
input: UpdateDailyConditionInput
|
||||||
): Promise<DailyConditions> => {
|
): Promise<string> => {
|
||||||
const response = await fetch(`${BASE_URL}/daily-conditions/${id}`, {
|
const response = await fetch(`${BASE_URL}/daily-conditions/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
body: JSON.stringify(data),
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка обновления условий')
|
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}`, {
|
const response = await fetch(`${BASE_URL}/daily-conditions/${id}`, {
|
||||||
method: 'DELETE',
|
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 = {
|
export const verificationConditionsUtils = {
|
||||||
// Валидация значения условия
|
// Валидация значения условия
|
||||||
validateConditionValue: (
|
validateConditionValue: (
|
||||||
value: any,
|
value: any,
|
||||||
dataType: string,
|
dataType: string = 'string',
|
||||||
minValue?: number,
|
minValue?: number,
|
||||||
maxValue?: number
|
maxValue?: number
|
||||||
): { isValid: boolean; error?: string } => {
|
): { isValid: boolean; error?: string } => {
|
||||||
@@ -201,11 +236,23 @@ export const verificationConditionsUtils = {
|
|||||||
return unit ? `${value} ${unit}` : value.toString()
|
return unit ? `${value} ${unit}` : value.toString()
|
||||||
},
|
},
|
||||||
|
|
||||||
// Получение ключа условия для хранения в conditions JSON
|
// Получение ключа условия для хранения в conditions JSON (обычно используется ID типа условия)
|
||||||
getConditionKey: (conditionTypeName: string): string => {
|
getConditionKey: (conditionTypeId: string): string => {
|
||||||
|
return conditionTypeId
|
||||||
|
},
|
||||||
|
|
||||||
|
// Генерация ключа на основе названия условия (для обратной совместимости)
|
||||||
|
generateConditionKey: (conditionTypeName: string): string => {
|
||||||
return conditionTypeName
|
return conditionTypeName
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/\s+/g, '_')
|
.replace(/\s+/g, '_')
|
||||||
.replace(/[^\w]/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 { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||||
import { CellTarget } from '@/type/template'
|
import { CellTarget } from '@/type/template'
|
||||||
import { Gauge } from 'lucide-react'
|
import { Building2 } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
VerificationConditionsConfig,
|
VerificationConditionsConfig,
|
||||||
VerificationConditionsValue,
|
VerificationConditionsValue,
|
||||||
@@ -9,70 +9,68 @@ import { VerificationConditionsEditor } from './VerificationConditionsEditor'
|
|||||||
import { VerificationConditionsPreview } from './VerificationConditionsPreview'
|
import { VerificationConditionsPreview } from './VerificationConditionsPreview'
|
||||||
import { VerificationConditionsRender } from './VerificationConditionsRender'
|
import { VerificationConditionsRender } from './VerificationConditionsRender'
|
||||||
|
|
||||||
export const verificationConditionsDefinition: ElementDefinition<
|
export const verificationConditionsElementDefinition: ElementDefinition<
|
||||||
VerificationConditionsConfig,
|
VerificationConditionsConfig & { targetCells?: CellTarget[] },
|
||||||
VerificationConditionsValue
|
VerificationConditionsValue
|
||||||
> = {
|
> = {
|
||||||
type: 'verification_conditions',
|
type: 'verification_conditions',
|
||||||
label: 'Условия поверки',
|
label: 'Условия поверки',
|
||||||
icon: <Gauge className="h-4 w-4" />,
|
icon: <Building2 className="h-4 w-4" />,
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
targetCells: [],
|
selectedLaboratoryId: undefined,
|
||||||
|
selectedDate: undefined,
|
||||||
conditionMappings: [],
|
conditionMappings: [],
|
||||||
|
showUnits: true,
|
||||||
|
targetCells: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Компоненты
|
||||||
Editor: VerificationConditionsEditor,
|
Editor: VerificationConditionsEditor,
|
||||||
Preview: VerificationConditionsPreview,
|
Preview: VerificationConditionsPreview,
|
||||||
Render: VerificationConditionsRender,
|
Render: VerificationConditionsRender,
|
||||||
|
|
||||||
mapToCells: config => {
|
// Получение ячеек для записи данных на основе маппинга условий
|
||||||
// Возвращаем настроенные пользователем целевые ячейки
|
mapToCells: (config): CellTarget[] => {
|
||||||
return config.targetCells.map(cell => ({
|
// Возвращаем только те targetCells, которые замаплены к условиям
|
||||||
sheet: cell.sheet,
|
if (!config.conditionMappings || !config.targetCells) {
|
||||||
cell: cell.cell,
|
return []
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
mapToCellValues: (config, value) => {
|
|
||||||
if (!value) 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({
|
return config.conditionMappings
|
||||||
target: {
|
.map(mapping => config.targetCells?.[mapping.cellIndex])
|
||||||
sheet: targetCell.sheet,
|
.filter(Boolean) as CellTarget[]
|
||||||
cell: targetCell.cell,
|
|
||||||
},
|
},
|
||||||
value: cellValue,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return results
|
// Получение значения для записи в ячейки на основе реального маппинга
|
||||||
|
mapToCellValues: (
|
||||||
|
config,
|
||||||
|
value: VerificationConditionsValue
|
||||||
|
): Array<{ target: CellTarget; value: any }> => {
|
||||||
|
if (!config.conditionMappings || !config.targetCells || !value.conditions) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 { useCallback, useEffect, useState } from 'react'
|
||||||
import { mockApiHelpers, mockLaboratories } from './mockData'
|
import { dailyConditionsApi, elementApi, laboratoriesApi } from './api'
|
||||||
import {
|
import { ConditionType, DailyCondition, Laboratory } from './types'
|
||||||
ElementConditionsData,
|
|
||||||
Laboratory,
|
|
||||||
ValidationError,
|
|
||||||
VerificationConditionsValue,
|
|
||||||
} from './types'
|
|
||||||
|
|
||||||
// Простой хук для загрузки лабораторий (используем моковые данные)
|
// Хук для загрузки лабораторий
|
||||||
export function useLaboratories() {
|
export function useLaboratories() {
|
||||||
const [data, setData] = useState<Laboratory[]>([])
|
const [data, setData] = useState<Laboratory[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@@ -17,9 +12,9 @@ export function useLaboratories() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
await mockApiHelpers.delay(300) // Симуляция загрузки
|
|
||||||
setData(mockLaboratories)
|
|
||||||
setIsError(false)
|
setIsError(false)
|
||||||
|
const laboratories = await laboratoriesApi.getAll()
|
||||||
|
setData(laboratories)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsError(true)
|
setIsError(true)
|
||||||
console.error('Ошибка загрузки лабораторий:', error)
|
console.error('Ошибка загрузки лабораторий:', error)
|
||||||
@@ -34,25 +29,127 @@ export function useLaboratories() {
|
|||||||
return { data, isLoading, isError }
|
return { data, isLoading, isError }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Хук для загрузки конкретной лаборатории
|
// Хук для работы с ежедневными условиями
|
||||||
export function useLaboratory(id: number | undefined) {
|
export function useDailyConditions(laboratoryId?: string, date?: string) {
|
||||||
const [data, setData] = useState<Laboratory | undefined>()
|
const [data, setData] = useState<DailyCondition | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isError, setIsError] = useState(false)
|
const [isError, setIsError] = useState(false)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) {
|
if (!laboratoryId || !date) {
|
||||||
setData(undefined)
|
setData(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
await mockApiHelpers.delay(200)
|
setIsError(false)
|
||||||
const laboratory = mockApiHelpers.getLaboratoryById(id)
|
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)
|
setData(laboratory)
|
||||||
setIsError(!laboratory)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsError(true)
|
setIsError(true)
|
||||||
console.error('Ошибка загрузки лаборатории:', error)
|
console.error('Ошибка загрузки лаборатории:', error)
|
||||||
@@ -62,370 +159,60 @@ export function useLaboratory(id: number | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadData()
|
loadData()
|
||||||
}, [id])
|
}, [laboratoryId])
|
||||||
|
|
||||||
return { data, isLoading, isError }
|
return { data, isLoading, isError }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Хук для загрузки условий для элемента
|
// Хук для управления ежедневными условиями с расширенным функционалом
|
||||||
export function useElementConditions(
|
export function useDailyConditionsManager() {
|
||||||
laboratoryId: number | undefined,
|
|
||||||
date: string | undefined
|
|
||||||
) {
|
|
||||||
const [data, setData] = useState<ElementConditionsData | undefined>()
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isError, setIsError] = useState(false)
|
const [isError, setIsError] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
const loadConditions = useCallback(
|
||||||
if (!laboratoryId || !date) {
|
async (filters: {
|
||||||
setData(undefined)
|
laboratoryId?: string
|
||||||
return
|
startDate?: string
|
||||||
}
|
endDate?: string
|
||||||
|
}) => {
|
||||||
const loadData = async () => {
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
await mockApiHelpers.delay(400)
|
setIsError(false)
|
||||||
const conditions = mockApiHelpers.getElementConditions(
|
const conditions = await dailyConditionsApi.get({
|
||||||
laboratoryId,
|
laboratory_id: filters.laboratoryId,
|
||||||
date
|
start_date: filters.startDate,
|
||||||
)
|
end_date: filters.endDate,
|
||||||
setData(conditions)
|
})
|
||||||
setIsError(!conditions)
|
return conditions
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsError(true)
|
setIsError(true)
|
||||||
console.error('Ошибка загрузки условий:', error)
|
console.error('Ошибка загрузки условий:', error)
|
||||||
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
loadData()
|
const deleteConditions = useCallback(async (conditionId: string) => {
|
||||||
}, [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 {
|
try {
|
||||||
setIsPending(true)
|
setIsLoading(true)
|
||||||
setIsError(false)
|
setIsError(false)
|
||||||
setError(null)
|
await dailyConditionsApi.delete(conditionId)
|
||||||
|
} catch (error) {
|
||||||
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)
|
setIsError(true)
|
||||||
setError(error)
|
console.error('Ошибка удаления условий:', error)
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
setIsPending(false)
|
setIsLoading(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: 'Должно быть числом' }
|
|
||||||
}
|
|
||||||
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 {
|
return {
|
||||||
laboratoryId: selectedLaboratoryId,
|
isLoading,
|
||||||
date: selectedDate,
|
isError,
|
||||||
conditions: localConditions,
|
loadConditions,
|
||||||
}
|
deleteConditions,
|
||||||
}, [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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { verificationConditionsDefinition } from './definition'
|
export { verificationConditionsElementDefinition as verificationConditionsDefinition } from './definition'
|
||||||
export { VerificationConditionsEditor } from './VerificationConditionsEditor'
|
export { VerificationConditionsEditor } from './VerificationConditionsEditor'
|
||||||
export { VerificationConditionsPreview } from './VerificationConditionsPreview'
|
export { VerificationConditionsPreview } from './VerificationConditionsPreview'
|
||||||
export { VerificationConditionsRender } from './VerificationConditionsRender'
|
export { VerificationConditionsRender } from './VerificationConditionsRender'
|
||||||
|
|||||||
@@ -1,280 +1,2 @@
|
|||||||
import { ConditionType, Laboratory } from './types'
|
// Флаг для переключения между mock и реальными данными
|
||||||
|
export const USE_MOCK_DATA = false
|
||||||
// Моковые типы условий (из БД схемы)
|
|
||||||
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)),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,109 +1,101 @@
|
|||||||
// Типы для элемента "Условия поверки"
|
// Типы для элемента "Условия поверки" на основе новой API схемы
|
||||||
|
|
||||||
export interface Laboratory {
|
// Маппинг условия в ячейку
|
||||||
id: number
|
export interface ConditionMapping {
|
||||||
name: string
|
conditionKey: string // ключ условия (temperature, humidity, etc.)
|
||||||
code: string
|
conditionName: string // название условия для отображения
|
||||||
description?: string
|
cellIndex: number // индекс целевой ячейки
|
||||||
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 VerificationConditionsConfig {
|
export interface VerificationConditionsConfig {
|
||||||
selectedLaboratoryId?: number // Выбранная лаборатория
|
// Выбранная лаборатория (UUID)
|
||||||
targetCells: Array<{
|
selectedLaboratoryId?: string
|
||||||
sheet: string
|
// Выбранная дата
|
||||||
cell: string
|
selectedDate?: string
|
||||||
}> // Целевые ячейки из основного редактора элементов
|
// Маппинг условий в ячейки
|
||||||
conditionMappings: ConditionCellMapping[] // Привязка условий к ячейкам
|
conditionMappings?: ConditionMapping[]
|
||||||
|
// Показывать ли единицы измерения
|
||||||
|
showUnits?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Значение элемента
|
// Значение элемента
|
||||||
export interface VerificationConditionsValue {
|
export interface VerificationConditionsValue {
|
||||||
laboratoryId: number
|
date: string // дата в формате YYYY-MM-DD
|
||||||
date: string
|
conditions: Record<string, string> // условия поверки {temperature: "20", humidity: "50", ...}
|
||||||
conditions: Record<string, any> // Значения условий {temperature: 23.5, humidity: 45.2}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ответы API
|
// Тип условия из API
|
||||||
export interface ApiResponse<T> {
|
export interface ConditionType {
|
||||||
success: boolean
|
id: string
|
||||||
data?: T
|
name: string
|
||||||
message?: string
|
unit: string
|
||||||
error?: string
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
deleted_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveConditionsResponse {
|
// Лаборатория из API
|
||||||
id: number
|
export interface Laboratory {
|
||||||
success: boolean
|
id: string
|
||||||
message: string
|
name: string
|
||||||
|
condition_types: ConditionType[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
deleted_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ошибки валидации
|
// Ежедневное условие из API
|
||||||
export interface ValidationError {
|
export interface DailyCondition {
|
||||||
field: string
|
id: string
|
||||||
message: string
|
laboratory_id: string
|
||||||
value?: any
|
measurement_date: string
|
||||||
min?: number
|
conditions: Record<string, any>
|
||||||
max?: number
|
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,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/component/ui/select'
|
} from '@/component/ui/select'
|
||||||
import { CellTarget } from '@/type/template'
|
|
||||||
import { Info, Settings } from 'lucide-react'
|
import { Info, Settings } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { StandardsSelector } from './StandardsSelector'
|
import { StandardsSelector } from './StandardsSelector'
|
||||||
|
import { StandardsConfig } from './definition'
|
||||||
interface StandardsConfig {
|
|
||||||
sheet: string // Лист Excel
|
|
||||||
startCell: string // Первая ячейка (например "A10")
|
|
||||||
maxItems: number // Обычно 7
|
|
||||||
targetCells: CellTarget[]
|
|
||||||
selectedStandards?: string[] // Выбранные эталоны (ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StandardsEditorProps {
|
interface StandardsEditorProps {
|
||||||
config: StandardsConfig
|
config: StandardsConfig
|
||||||
@@ -32,8 +24,6 @@ export const StandardsEditor: React.FC<StandardsEditorProps> = ({
|
|||||||
|
|
||||||
// Обеспечиваем значения по умолчанию
|
// Обеспечиваем значения по умолчанию
|
||||||
const safeConfig = {
|
const safeConfig = {
|
||||||
sheet: config.sheet || 'L',
|
|
||||||
startCell: config.startCell || 'A1',
|
|
||||||
maxItems: config.maxItems || 7,
|
maxItems: config.maxItems || 7,
|
||||||
targetCells: config.targetCells || [],
|
targetCells: config.targetCells || [],
|
||||||
selectedStandards: config.selectedStandards || [],
|
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 { textDefinition } from '@/component/BasicElements'
|
||||||
import { calibrationConditionsDefinition } from '@/component/BasicElements/CalibrationConditionsElement'
|
|
||||||
import { numberDefinition } from '@/component/BasicElements/NumberElement'
|
import { numberDefinition } from '@/component/BasicElements/NumberElement'
|
||||||
import { selectDefinition } from '@/component/BasicElements/SelectElement'
|
import { selectDefinition } from '@/component/BasicElements/SelectElement'
|
||||||
import { standardsDefinition } from '@/component/StandardsElement/definition'
|
import { verificationConditionsElementDefinition as verificationConditionsDefinition } from '@/component/VerificationConditionsElement/definition'
|
||||||
import { verificationConditionsDefinition } from '@/component/VerificationConditionsElement/definition'
|
|
||||||
import { buttonGroupDefinition } from '@/entitiy/element/model/implementations/ButtonGroup'
|
import { buttonGroupDefinition } from '@/entitiy/element/model/implementations/ButtonGroup'
|
||||||
import { dateDefinition } from '@/entitiy/element/model/implementations/DateElement'
|
import { dateDefinition } from '@/entitiy/element/model/implementations/DateElement'
|
||||||
|
import { standardsDefinition } from '@/entitiy/element/model/implementations/StandardsElement/definition'
|
||||||
import { CellTarget } from '@/type/template'
|
import { CellTarget } from '@/type/template'
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
@@ -67,7 +66,6 @@ export const elementDefinitions = {
|
|||||||
number: numberDefinition,
|
number: numberDefinition,
|
||||||
date: dateDefinition,
|
date: dateDefinition,
|
||||||
standards: standardsDefinition,
|
standards: standardsDefinition,
|
||||||
calibration_conditions: calibrationConditionsDefinition,
|
|
||||||
verification_conditions: verificationConditionsDefinition,
|
verification_conditions: verificationConditionsDefinition,
|
||||||
button_group: buttonGroupDefinition,
|
button_group: buttonGroupDefinition,
|
||||||
} as const
|
} as const
|
||||||
@@ -80,7 +78,6 @@ export function initializeElementRegistry() {
|
|||||||
registerElement('number', numberDefinition)
|
registerElement('number', numberDefinition)
|
||||||
registerElement('date', dateDefinition)
|
registerElement('date', dateDefinition)
|
||||||
registerElement('button_group', buttonGroupDefinition)
|
registerElement('button_group', buttonGroupDefinition)
|
||||||
registerElement('calibration_conditions', calibrationConditionsDefinition)
|
|
||||||
registerElement('standards', standardsDefinition)
|
registerElement('standards', standardsDefinition)
|
||||||
registerElement('verification_conditions', verificationConditionsDefinition)
|
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 { Badge } from '@/component/ui/badge'
|
||||||
import { Button } from '@/component/ui/button'
|
import { Button } from '@/component/ui/button'
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +8,17 @@ import {
|
|||||||
} from '@/component/ui/dialog'
|
} from '@/component/ui/dialog'
|
||||||
import { Input } from '@/component/ui/input'
|
import { Input } from '@/component/ui/input'
|
||||||
import { Label } from '@/component/ui/label'
|
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 {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Edit,
|
Edit,
|
||||||
|
|||||||
@@ -12,46 +12,46 @@ export default {
|
|||||||
foreground: 'hsl(var(--foreground))',
|
foreground: 'hsl(var(--foreground))',
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: 'hsl(var(--primary))',
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
foreground: 'hsl(var(--primary-foreground))'
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
foreground: 'hsl(var(--secondary-foreground))'
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
},
|
},
|
||||||
destructive: {
|
destructive: {
|
||||||
DEFAULT: 'hsl(var(--destructive))',
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
foreground: 'hsl(var(--destructive-foreground))'
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
foreground: 'hsl(var(--muted-foreground))'
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: 'hsl(var(--accent))',
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
foreground: 'hsl(var(--accent-foreground))'
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
DEFAULT: 'hsl(var(--popover))',
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
foreground: 'hsl(var(--popover-foreground))'
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: 'hsl(var(--card))',
|
DEFAULT: 'hsl(var(--card))',
|
||||||
foreground: 'hsl(var(--card-foreground))'
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
},
|
},
|
||||||
chart: {
|
chart: {
|
||||||
'1': 'hsl(var(--chart-1))',
|
1: 'hsl(var(--chart-1))',
|
||||||
'2': 'hsl(var(--chart-2))',
|
2: 'hsl(var(--chart-2))',
|
||||||
'3': 'hsl(var(--chart-3))',
|
3: 'hsl(var(--chart-3))',
|
||||||
'4': 'hsl(var(--chart-4))',
|
4: 'hsl(var(--chart-4))',
|
||||||
'5': 'hsl(var(--chart-5))'
|
5: 'hsl(var(--chart-5))',
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: 'var(--radius)',
|
lg: 'var(--radius)',
|
||||||
md: 'calc(var(--radius) - 2px)',
|
md: 'calc(var(--radius) - 2px)',
|
||||||
sm: 'calc(var(--radius) - 4px)'
|
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"
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6"
|
||||||
integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==
|
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":
|
"@dnd-kit/accessibility@^3.1.1":
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz#3b4202bd6bb370a0730f6734867785919beac6af"
|
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"
|
es-errors "^1.3.0"
|
||||||
is-data-view "^1.0.1"
|
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:
|
debug@^3.2.7:
|
||||||
version "3.2.7"
|
version "3.2.7"
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
|
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"
|
redux "^4.0.4"
|
||||||
use-memo-one "^1.1.1"
|
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:
|
react-dom@^18.2.0:
|
||||||
version "18.3.1"
|
version "18.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"
|
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"
|
||||||
|
|||||||
Reference in New Issue
Block a user