почти рабочий вариант

This commit is contained in:
2025-07-24 19:56:58 +03:00
parent f9e7905d55
commit 1f02136455
29 changed files with 1627 additions and 3205 deletions

View File

@@ -34,6 +34,7 @@
"@types/react-window": "^1.8.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"immer": "^10.1.1",
@@ -41,6 +42,7 @@
"lucide-react": "^0.525.0",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-day-picker": "^9.8.0",
"react-dom": "^18.2.0",
"react-grid-layout": "^1.5.2",
"react-rnd": "^10.5.2",

View File

@@ -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
}}
/>
)
},
}

View File

@@ -1,6 +1,5 @@
export { textDefinition } from '@/entitiy/element/model/implementations/TextElement'
export { buttonGroupDefinition } from '../../entitiy/element/model/implementations/ButtonGroup'
export { dateDefinition } from '../../entitiy/element/model/implementations/DateElement'
export { calibrationConditionsDefinition } from './CalibrationConditionsElement'
export { numberDefinition } from './NumberElement'
export { selectDefinition } from './SelectElement'

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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] || '', // Берем значение по индексу или пустую строку
}))
},
}

View File

@@ -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 'ЭТЛ'
}
}

View File

@@ -139,7 +139,7 @@ const CellTargetEditor: React.FC<{
{/* Поле ввода ячейки */}
<Input
placeholder="A1"
placeholder="A12"
value={target.cell}
onChange={e => handleCellInputChange(index, e.target.value)}
className="h-6 w-16 px-2 text-xs"
@@ -231,36 +231,6 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
? getElementDefinition(formData.type)
: null
const handleAddOption = () => {
setFormData(prev => ({
...prev,
options: [...(prev.options || []), { value: '', label: '' }],
}))
}
const handleUpdateOption = (
index: number,
field: 'value' | 'label',
value: string
) => {
setFormData(prev => ({
...prev,
options: prev.options?.map((option, i) =>
i === index ? { ...option, [field]: value } : option
),
}))
}
const handleRemoveOption = (index: number) => {
setFormData(prev => ({
...prev,
options: prev.options?.filter((_, i) => i !== index),
}))
}
const needsOptions =
formData.type === 'select' || formData.type === 'button_group'
return (
<div className="grid h-full grid-cols-1 gap-6 lg:grid-cols-2">
{/* Настройки элемента */}
@@ -394,69 +364,6 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
</CardContent>
</Card>
)}
{/* Варианты для select/radio/button_group */}
{needsOptions && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-orange-600" />
<CardTitle className="text-sm">Варианты выбора</CardTitle>
</div>
<Button
variant="outline"
size="sm"
onClick={handleAddOption}
className="h-7 px-2 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
Добавить
</Button>
</div>
</CardHeader>
<CardContent>
<div className="max-h-32 space-y-2 overflow-y-auto">
{formData.options?.map((option, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder="Значение"
value={option.value}
onChange={e =>
handleUpdateOption(index, 'value', e.target.value)
}
className="h-8 text-xs"
/>
<Input
placeholder="Подпись"
value={option.label}
onChange={e =>
handleUpdateOption(index, 'label', e.target.value)
}
className="h-8 text-xs"
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveOption(index)}
className="h-8 w-8 text-red-600 hover:bg-red-50 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{(!formData.options || formData.options.length === 0) && (
<div className="py-4 text-center">
<Tag className="mx-auto mb-2 h-6 w-6 text-gray-300" />
<p className="text-xs text-gray-500">
Добавьте варианты выбора
</p>
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
{/* Предпросмотр */}

View File

@@ -1,5 +1,4 @@
import { Badge } from '@/component/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
import { Label } from '@/component/ui/label'
import {
Select,
@@ -8,13 +7,13 @@ import {
SelectTrigger,
SelectValue,
} from '@/component/ui/select'
import { Building2, Target } from 'lucide-react'
import { useLaboratories } from './hooks'
import { mockApiHelpers } from './mockData'
import { ConditionCellMapping, VerificationConditionsConfig } from './types'
import { CellTarget } from '@/type/template'
import { Building2 } from 'lucide-react'
import { useLaboratories, useLaboratoryConditions } from './hooks'
import { ConditionMapping, VerificationConditionsConfig } from './types'
interface VerificationConditionsEditorProps {
config: VerificationConditionsConfig
config: VerificationConditionsConfig & { targetCells?: CellTarget[] }
onChange: (config: VerificationConditionsConfig) => void
}
@@ -23,204 +22,198 @@ export function VerificationConditionsEditor({
onChange,
}: VerificationConditionsEditorProps) {
const { data: laboratories = [], isLoading } = useLaboratories()
const { data: labData } = useLaboratoryConditions(config.selectedLaboratoryId)
const updateConfig = (updates: Partial<VerificationConditionsConfig>) => {
onChange({ ...config, ...updates })
}
// Получаем выбранную лабораторию
const selectedLab = laboratories.find(
lab => lab.id === config.selectedLaboratoryId
)
// Получить доступные целевые ячейки из конфигурации
const getAvailableTargetCells = () => {
return config.targetCells || []
}
// Получаем все доступные условия (либо все из БД, либо условия конкретной лаборатории)
const availableConditions = config.selectedLaboratoryId
? mockApiHelpers.getLaboratoryConditions(config.selectedLaboratoryId)
: mockApiHelpers.getAllConditions()
// Обновить маппинг для конкретного условия
const updateConditionMapping = (
conditionId: string,
conditionName: string,
targetCellKey?: string // формат: "sheet:cell", например "L:A1"
) => {
const currentMappings = config.conditionMappings || []
let updatedMappings = [...currentMappings]
// Обновление привязки условия к ячейке
const updateConditionMapping = (cellIndex: number, conditionKey: string) => {
const conditionName =
availableConditions.find(c => c.key === conditionKey)?.name ||
conditionKey
// Удаляем существующий маппинг для этого условия
updatedMappings = updatedMappings.filter(
mapping => mapping.conditionKey !== conditionId
)
const newMappings = [...(config.conditionMappings || [])]
const existingIndex = newMappings.findIndex(m => m.cellIndex === cellIndex)
// Если выбрана ячейка, добавляем новый маппинг
if (targetCellKey) {
const [sheet, cell] = targetCellKey.split(':')
const targetCells = getAvailableTargetCells()
const targetIndex = targetCells.findIndex(
tc => tc.sheet === sheet && tc.cell === cell
)
if (conditionKey === '__none__') {
// Убираем привязку
if (existingIndex >= 0) {
newMappings.splice(existingIndex, 1)
}
} else {
// Добавляем или обновляем привязку
const mapping: ConditionCellMapping = {
cellIndex,
conditionKey,
conditionName,
}
if (existingIndex >= 0) {
newMappings[existingIndex] = mapping
} else {
newMappings.push(mapping)
if (targetIndex !== -1) {
const newMapping: ConditionMapping = {
conditionKey: conditionId,
conditionName: conditionName,
cellIndex: targetIndex, // используем индекс в массиве targetCells
}
updatedMappings.push(newMapping)
}
}
updateConfig({ conditionMappings: newMappings })
updateConfig({ conditionMappings: updatedMappings })
}
// Получить выбранное условие для ячейки
const getSelectedCondition = (cellIndex: number): string => {
const mapping = config.conditionMappings?.find(
m => m.cellIndex === cellIndex
// Получить выбранную ячейку для условия
const getSelectedTargetCell = (conditionId: string): string | undefined => {
const mapping = (config.conditionMappings || []).find(
mapping => mapping.conditionKey === conditionId
)
return mapping?.conditionKey || '__none__'
if (!mapping) return undefined
const targetCells = getAvailableTargetCells()
const targetCell = targetCells[mapping.cellIndex]
return targetCell ? `${targetCell.sheet}:${targetCell.cell}` : undefined
}
// Получить занятые ячейки (исключая текущее условие)
const getOccupiedTargetCells = (excludeConditionId?: string): string[] => {
const targetCells = getAvailableTargetCells()
return (config.conditionMappings || [])
.filter(mapping => mapping.conditionKey !== excludeConditionId)
.map(mapping => {
const targetCell = targetCells[mapping.cellIndex]
return targetCell ? `${targetCell.sheet}:${targetCell.cell}` : ''
})
.filter(Boolean)
}
const targetCells = getAvailableTargetCells()
return (
<div className="space-y-4">
{/* Выбор лаборатории */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-4 w-4" />
Лаборатория
</CardTitle>
</CardHeader>
<CardContent>
<div>
<Label htmlFor="laboratory">Выберите лабораторию</Label>
<Select
value={config.selectedLaboratoryId?.toString() || '__none__'}
onValueChange={value => {
const laboratoryId =
value === '__none__' ? undefined : parseInt(value)
updateConfig({
selectedLaboratoryId: laboratoryId,
conditionMappings: [], // Сбрасываем привязки при смене лаборатории
})
}}
>
<SelectTrigger>
<SelectValue placeholder="Выберите лабораторию" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Все условия из БД</SelectItem>
{laboratories.map(lab => (
<SelectItem key={lab.id} value={lab.id.toString()}>
{lab.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
{config.selectedLaboratoryId
? 'Доступны только условия выбранной лаборатории'
: 'Доступны все условия из базы данных'}
</p>
</div>
</CardContent>
</Card>
{/* Компактный выбор лаборатории */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-blue-600" />
<Label className="text-sm font-medium">Лаборатория</Label>
</div>
<div className="flex-1">
<Select
value={config.selectedLaboratoryId || ''}
onValueChange={value =>
updateConfig({
selectedLaboratoryId: value || undefined,
})
}
disabled={isLoading}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue
placeholder={isLoading ? 'Загрузка...' : 'Выберите лабораторию'}
/>
</SelectTrigger>
<SelectContent>
{laboratories.map(lab => (
<SelectItem key={lab.id} value={lab.id}>
{lab.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Привязка условий к ячейкам */}
{config.targetCells.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-4 w-4" />
Привязка условий к ячейкам
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-800 dark:bg-blue-950 dark:text-blue-200">
<p className="font-medium">Настройка записи данных</p>
<p className="mt-1">
Выберите какое условие будет записываться в каждую целевую
ячейку.
</p>
</div>
{/* Маппинг условий */}
{labData && targetCells.length > 0 && (
<div className="space-y-3">
<Label className="text-sm font-medium text-gray-700">
Маппинг условий поверки
</Label>
<div className="space-y-2">
{labData.conditionTypes.map(conditionType => {
const selectedCell = getSelectedTargetCell(conditionType.id)
const occupiedCells = getOccupiedTargetCells(conditionType.id)
<div className="space-y-3">
{config.targetCells.map((cell, index) => (
return (
<div
key={index}
className="flex items-center gap-3 rounded-md border p-3"
key={conditionType.id}
className="flex items-center justify-between gap-3 rounded-md border border-gray-200 bg-gray-50/50 px-3 py-2"
>
<Badge variant="outline" className="shrink-0">
{cell.sheet}!{cell.cell}
</Badge>
{/* Информация об условии */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">
{conditionType.name}
</span>
<Badge variant="secondary" className="px-2 py-0.5 text-xs">
{conditionType.unit}
</Badge>
</div>
<div className="flex-1">
<Label className="text-xs text-muted-foreground">
Ячейка {index + 1} - выберите условие:
</Label>
<Select
value={getSelectedCondition(index)}
onValueChange={value =>
updateConditionMapping(index, value)
}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Выберите условие" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Не выбрано</SelectItem>
{availableConditions.map(condition => (
<SelectItem key={condition.key} value={condition.key}>
<div className="flex items-center gap-2">
<span>{condition.name}</span>
{condition.unit && (
<Badge variant="secondary" className="text-xs">
{condition.unit}
</Badge>
)}
</div>
{/* Компактный селектор ячейки */}
<Select
value={selectedCell || 'UNSELECTED'}
onValueChange={value =>
updateConditionMapping(
conditionType.id,
conditionType.name,
value === 'UNSELECTED' ? undefined : value
)
}
>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value="UNSELECTED"></SelectItem>
{targetCells
.map(
(target, index) => `${target.sheet}:${target.cell}`
)
.filter(cellKey => !occupiedCells.includes(cellKey))
.map(cellKey => (
<SelectItem key={cellKey} value={cellKey}>
{cellKey}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</SelectContent>
</Select>
</div>
))}
</div>
)
})}
{/* Подсказка с доступными условиями */}
<div className="rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
<p className="font-medium">
Доступные условия{' '}
{selectedLab ? `для ${selectedLab.name}` : 'из базы данных'}:
</p>
<ul className="mt-1 space-y-1">
{availableConditions.map(condition => (
<li key={condition.key}>
<strong>{condition.name}</strong>
{condition.unit && ` (${condition.unit})`}
{'isRequired' in condition &&
condition.isRequired &&
' - обязательное'}
</li>
))}
</ul>
</div>
</CardContent>
</Card>
{labData.conditionTypes.length === 0 && (
<div className="rounded-md border border-dashed border-gray-300 py-4 text-center">
<p className="text-xs text-gray-500">
У выбранной лаборатории нет настроенных типов условий
</p>
</div>
)}
</div>
</div>
)}
{/* Подсказка если нет целевых ячеек */}
{config.targetCells.length === 0 && (
<Card>
<CardContent className="pt-6">
<div className="text-center text-sm text-muted-foreground">
<Target className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p className="font-medium">Целевые ячейки не настроены</p>
<p className="mt-1">
Сначала настройте целевые ячейки в основном редакторе элементов,
затем вернитесь сюда для привязки условий.
</p>
</div>
</CardContent>
</Card>
{/* Предупреждение если нет целевых ячеек */}
{targetCells.length === 0 && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2">
<p className="text-xs text-amber-700">
Добавьте целевые ячейки в настройки элемента для маппинга условий
поверки
</p>
</div>
)}
{/* Предупреждение если выбрана лаборатория но нет ячеек */}
{labData && targetCells.length === 0 && (
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
<p className="text-xs text-blue-700">
Настройте целевые ячейки для сопоставления условий поверки
</p>
</div>
)}
</div>
)

View File

@@ -1,129 +1,182 @@
import { Badge } from '@/component/ui/badge'
import { Button } from '@/component/ui/button'
import { Calendar } from '@/component/ui/calendar'
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
import { Building2, Calendar, Droplets, Gauge, Thermometer } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/component/ui/popover'
import {
AlertCircle,
Building2,
Calendar as CalendarIcon,
CheckCircle,
Plus,
} from 'lucide-react'
import { useState } from 'react'
import {
useDailyConditions,
useLaboratories,
useLaboratoryConditions,
} from './hooks'
import { VerificationConditionsConfig } from './types'
interface VerificationConditionsPreviewProps {
config: VerificationConditionsConfig
}
// Моковые данные для предварительного просмотра
const mockData = {
laboratory: {
id: 1,
name: 'Лаборатория температуры',
code: 'TEMP_LAB',
},
date: '2024-01-15',
conditions: [
{
typeName: 'Температура',
value: 23.5,
unit: '°C',
icon: <Thermometer className="h-3 w-3" />,
},
{
typeName: 'Влажность',
value: 45.2,
unit: '%',
icon: <Droplets className="h-3 w-3" />,
},
{
typeName: 'Атмосферное давление',
value: 101.3,
unit: 'кПа',
icon: <Gauge className="h-3 w-3" />,
},
],
onChange?: (config: VerificationConditionsConfig) => void
}
export function VerificationConditionsPreview({
config,
onChange,
}: VerificationConditionsPreviewProps) {
const displayedConditions = mockData.conditions.slice(0, config.maxConditions)
const [selectedDate, setSelectedDate] = useState(
config.selectedDate || new Date().toISOString().split('T')[0]
)
const [calendarOpen, setCalendarOpen] = useState(false)
const { data: laboratories = [] } = useLaboratories()
const { data: labData } = useLaboratoryConditions(config.selectedLaboratoryId)
const { data: dailyCondition, isLoading } = useDailyConditions(
config.selectedLaboratoryId,
selectedDate
)
const selectedLab = laboratories.find(
lab => lab.id === config.selectedLaboratoryId
)
const hasConditions =
dailyCondition && Object.keys(dailyCondition.conditions).length > 0
const handleDateChange = (date: string) => {
setSelectedDate(date)
if (onChange) {
onChange({ ...config, selectedDate: date })
}
}
const handleCalendarSelect = (date: Date | undefined) => {
if (date) {
const dateString = date.toISOString().split('T')[0]
setSelectedDate(dateString)
setCalendarOpen(false)
if (onChange) {
onChange({ ...config, selectedDate: dateString })
}
}
}
if (!config.selectedLaboratoryId) {
return (
<Card className="border-dashed">
<CardContent className="p-3">
<div className="text-center text-muted-foreground">
<Building2 className="mx-auto mb-1 h-4 w-4 opacity-50" />
<p className="text-xs">Лаборатория не выбрана</p>
</div>
</CardContent>
</Card>
)
}
if (!labData) {
return (
<Card>
<CardContent className="p-3">
<div className="text-center text-muted-foreground">
<div className="mx-auto mb-1 h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-xs">Загрузка...</p>
</div>
</CardContent>
</Card>
)
}
return (
<Card className="w-full max-w-md">
<CardHeader className="pb-3">
<CardTitle className="text-sm">Условия поверки</CardTitle>
<Card>
<CardHeader className="px-3 pb-1 pt-2">
<CardTitle className="flex items-center gap-1.5 text-sm">
<Building2 className="h-3.5 w-3.5" />
<span className="truncate">{selectedLab?.name || 'Лаборатория'}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Селекторы лаборатории и даты */}
<div className="space-y-3">
<div>
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
<Building2 className="h-3 w-3" />
Лаборатория
</div>
<div className="rounded-md border bg-muted/30 p-2 text-sm">
{config.selectedLaboratoryId
? mockData.laboratory.name
: 'Не выбрана'}
</div>
</div>
<div>
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
<Calendar className="h-3 w-3" />
Дата
</div>
<div className="rounded-md border bg-muted/30 p-2 text-sm">
{config.selectedDate || mockData.date}
</div>
</div>
</div>
{/* Условия */}
<div>
<div className="mb-2 text-xs font-medium text-muted-foreground">
Условия
</div>
<div className="space-y-2">
{displayedConditions.map((condition, index) => (
<div
key={index}
className="flex items-center justify-between rounded-md border bg-background p-2 text-sm"
<CardContent className="space-y-2 px-3 pb-3">
{/* Выбор даты */}
<div className="flex items-center gap-2">
<CalendarIcon className="h-3.5 w-3.5 text-muted-foreground" />
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-6 justify-start border-none bg-muted/50 px-2 text-xs font-normal hover:bg-muted/70"
disabled={!onChange}
>
<div className="flex items-center gap-2">
{condition.icon}
<span className="font-medium">{condition.typeName}</span>
</div>
<div className="flex items-center gap-1">
<span>{condition.value}</span>
{config.showUnits && condition.unit && (
<Badge variant="secondary" className="text-xs">
{condition.unit}
</Badge>
)}
</div>
</div>
))}
{mockData.conditions.length > config.maxConditions && (
<div className="text-center text-xs text-muted-foreground">
... и ещё {mockData.conditions.length - config.maxConditions}{' '}
условий
</div>
)}
</div>
{new Date(selectedDate).toLocaleDateString('ru-RU')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={new Date(selectedDate)}
onSelect={handleCalendarSelect}
/>
</PopoverContent>
</Popover>
</div>
{/* Настройки */}
<div className="rounded-md bg-muted/50 p-2 text-xs text-muted-foreground">
<div className="space-y-1">
<div>
Лист: <span className="font-mono">{config.sheet}</span>
{/* Статус и данные */}
<div className="border-t pt-2">
{isLoading ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span>Загрузка...</span>
</div>
<div>
Ячейка: <span className="font-mono">{config.startCell}</span>
) : hasConditions ? (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs text-green-600">
<CheckCircle className="h-3.5 w-3.5" />
<span className="font-medium">Данные внесены</span>
</div>
<div className="space-y-1">
{labData.conditionTypes.map(conditionType => {
const conditionValue =
dailyCondition!.conditions[conditionType.id]
return (
<div
key={conditionType.id}
className="flex items-center justify-between rounded bg-muted/30 px-2 py-1 text-xs"
>
<span className="truncate font-medium">
{conditionType.name}
</span>
<span className="ml-2 font-mono text-muted-foreground">
{conditionValue || '—'}
{conditionValue && conditionType.unit && (
<span className="ml-1">{conditionType.unit}</span>
)}
</span>
</div>
)
})}
</div>
</div>
<div>Макс. условий: {config.maxConditions}</div>
{config.autoSave && <div> Автосохранение</div>}
{config.showUnits && <div> Показывать единицы</div>}
{config.targetCells.length > 0 && (
<div>Ячеек настроено: {config.targetCells.length}</div>
)}
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs text-orange-600">
<AlertCircle className="h-3.5 w-3.5" />
<span className="font-medium">Данные не внесены</span>
</div>
<Button
disabled
size="sm"
className="h-6 w-full text-xs"
variant="outline"
>
<Plus className="mr-1.5 h-3 w-3" />
Внести данные
</Button>
</div>
)}
</div>
</CardContent>
</Card>

View File

@@ -1,30 +1,30 @@
import { Alert, AlertDescription } from '@/component/ui/alert'
import { Badge } from '@/component/ui/badge'
import { Button } from '@/component/ui/button'
import { Calendar } from '@/component/ui/calendar'
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/component/ui/dialog'
import { Input } from '@/component/ui/input'
import { Label } from '@/component/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/component/ui/select'
import { Popover, PopoverContent, PopoverTrigger } from '@/component/ui/popover'
import {
AlertCircle,
Building2,
Calendar,
CheckCircle2,
Droplets,
Gauge,
Loader2,
Save,
Thermometer,
Zap,
Calendar as CalendarIcon,
CheckCircle,
Plus,
Settings,
} from 'lucide-react'
import { useEffect } from 'react'
import { useVerificationConditionsElement } from './hooks'
import { useEffect, useState } from 'react'
import {
useDailyConditions,
useLaboratories,
useLaboratoryConditions,
} from './hooks'
import {
VerificationConditionsConfig,
VerificationConditionsValue,
@@ -34,277 +34,352 @@ interface VerificationConditionsRenderProps {
config: VerificationConditionsConfig
value?: VerificationConditionsValue
onChange?: (value: VerificationConditionsValue) => void
}
// Иконки для разных типов условий
const getConditionIcon = (conditionName: string) => {
const name = conditionName.toLowerCase()
if (name.includes('температур')) return <Thermometer className="h-3 w-3" />
if (name.includes('влажн')) return <Droplets className="h-3 w-3" />
if (name.includes('давлен')) return <Gauge className="h-3 w-3" />
if (name.includes('напряжен') || name.includes('частот'))
return <Zap className="h-3 w-3" />
return <div className="h-3 w-3 rounded-full bg-muted" />
onSave?: (value: VerificationConditionsValue) => Promise<void>
}
export function VerificationConditionsRender({
config,
value,
onChange,
onSave,
}: VerificationConditionsRenderProps) {
const [selectedDate, setSelectedDate] = useState(
config.selectedDate || new Date().toISOString().split('T')[0]
)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [calendarOpen, setCalendarOpen] = useState(false)
const [editingConditions, setEditingConditions] = useState<
Record<string, string>
>({})
const { data: laboratories = [] } = useLaboratories()
const { data: labData } = useLaboratoryConditions(config.selectedLaboratoryId)
const {
laboratories,
selectedLaboratory,
selectedLaboratoryId,
selectedDate,
conditions,
localConditions,
isLoading,
isError,
isSaving,
hasUnsavedChanges,
validationErrors,
changeLaboratory,
changeDate,
updateCondition,
data: dailyCondition,
saveConditions,
getCurrentValue,
formatValueWithUnit,
getConditionKey,
} = useVerificationConditionsElement({
selectedLaboratoryId: config.selectedLaboratoryId || value?.laboratoryId,
selectedDate: config.selectedDate || value?.date,
autoSave: config.autoSave,
})
isSaving,
} = useDailyConditions(config.selectedLaboratoryId, selectedDate)
// Синхронизируем значение с родительским компонентом
const selectedLab = laboratories.find(
lab => lab.id === config.selectedLaboratoryId
)
const hasConditions =
dailyCondition && Object.keys(dailyCondition.conditions).length > 0
// Автоматическая инициализация onChange при загрузке данных
useEffect(() => {
if (onChange) {
const currentValue = getCurrentValue()
if (
currentValue &&
(!value ||
currentValue.laboratoryId !== value.laboratoryId ||
currentValue.date !== value.date ||
JSON.stringify(currentValue.conditions) !==
JSON.stringify(value.conditions))
) {
onChange(currentValue)
}
if (dailyCondition && onChange && hasConditions) {
onChange({
date: selectedDate,
conditions: dailyCondition.conditions,
})
}
}, [onChange, getCurrentValue, value])
}, [dailyCondition, onChange, selectedDate, hasConditions])
const handleDateChange = (date: string) => {
setSelectedDate(date)
}
const handleCalendarSelect = (date: Date | undefined) => {
if (date) {
const dateString = date.toISOString().split('T')[0]
setSelectedDate(dateString)
setCalendarOpen(false)
}
}
const handleOpenEditDialog = (isCreating = false) => {
if (isCreating) {
const emptyConditions: Record<string, string> = {}
labData?.conditionTypes.forEach(conditionType => {
emptyConditions[conditionType.id] = ''
})
setEditingConditions(emptyConditions)
} else {
setEditingConditions(dailyCondition?.conditions || {})
}
setEditDialogOpen(true)
}
const handleSaveConditions = async () => {
if (!config.selectedLaboratoryId || !selectedDate || !labData) return
// Обработка сохранения
const handleSave = async () => {
try {
await saveConditions()
await saveConditions(editingConditions)
if (onChange) {
onChange({
date: selectedDate,
conditions: editingConditions,
})
}
setEditDialogOpen(false)
} catch (error) {
console.error('Ошибка сохранения:', error)
}
}
// Получение ошибки валидации для поля
const getFieldError = (fieldKey: string) => {
return validationErrors.find(error => error.field === fieldKey)
const handleConditionChange = (conditionId: string, value: string) => {
setEditingConditions(prev => ({
...prev,
[conditionId]: value,
}))
}
if (isLoading) {
if (!config.selectedLaboratoryId) {
return (
<Card>
<CardContent className="flex items-center justify-center p-6">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Загрузка...
<Card className="border-dashed">
<CardContent className="p-3">
<div className="text-center text-muted-foreground">
<AlertCircle className="mx-auto mb-1 h-4 w-4 opacity-50" />
<p className="text-xs">Лаборатория не настроена</p>
</div>
</CardContent>
</Card>
)
}
if (isError) {
if (!labData) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Ошибка загрузки данных условий поверки
</AlertDescription>
</Alert>
<Card>
<CardContent className="p-3">
<div className="text-center text-muted-foreground">
<div className="mx-auto mb-1 h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-xs">Загрузка...</p>
</div>
</CardContent>
</Card>
)
}
return (
<Card className="w-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between text-sm">
<span>Условия поверки</span>
{hasUnsavedChanges && !config.autoSave && (
<Button
size="sm"
onClick={handleSave}
disabled={isSaving || validationErrors.length > 0}
className="h-7"
>
{isSaving ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Save className="mr-1 h-3 w-3" />
)}
Сохранить
</Button>
)}
<Card>
<CardHeader className="px-3 pb-1 pt-2">
<CardTitle className="flex items-center gap-1.5 text-sm">
<Building2 className="h-3.5 w-3.5" />
<span className="truncate">{selectedLab?.name || 'Лаборатория'}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Селекторы лаборатории и даты */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<Label className="flex items-center gap-2 text-xs font-medium">
<Building2 className="h-3 w-3" />
Лаборатория
</Label>
<Select
value={selectedLaboratoryId?.toString() || ''}
onValueChange={value => changeLaboratory(parseInt(value))}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Выберите лабораторию" />
</SelectTrigger>
<SelectContent>
{laboratories.map(lab => (
<SelectItem key={lab.id} value={lab.id.toString()}>
<div>
<div className="font-medium">{lab.name}</div>
<div className="text-xs text-muted-foreground">
{lab.code}
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="flex items-center gap-2 text-xs font-medium">
<Calendar className="h-3 w-3" />
Дата
</Label>
<Input
type="date"
value={selectedDate || ''}
onChange={e => changeDate(e.target.value)}
className="mt-1"
/>
</div>
<CardContent className="space-y-2 px-3 pb-3">
{/* Выбор даты */}
<div className="flex items-center gap-2">
<CalendarIcon className="h-3.5 w-3.5 text-muted-foreground" />
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-6 justify-start border-none bg-muted/50 px-2 text-xs font-normal hover:bg-muted/70"
>
{new Date(selectedDate).toLocaleDateString('ru-RU')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={new Date(selectedDate)}
onSelect={handleCalendarSelect}
className="rounded-md border shadow-sm"
captionLayout="dropdown"
/>
</PopoverContent>
</Popover>
</div>
{/* Условия */}
{conditions && selectedLaboratoryId && selectedDate && (
<div>
<Label className="text-xs font-medium text-muted-foreground">
Условия поверки
</Label>
<div className="mt-2 space-y-2">
{conditions.conditions
.slice(0, config.maxConditions)
.map((condition, index) => {
const key = getConditionKey(condition.typeName)
const error = getFieldError(key)
{/* Статус и данные */}
<div className="border-t pt-2">
{hasConditions ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-green-600">
<CheckCircle className="h-3.5 w-3.5" />
<span className="font-medium">Данные внесены</span>
</div>
return (
<div
key={condition.typeId}
className="flex items-center gap-3 rounded-md border bg-background p-3"
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-6 px-2 text-xs"
onClick={() => handleOpenEditDialog(false)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
{getConditionIcon(condition.typeName)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{condition.typeName}
</div>
{config.showUnits && condition.unit && (
<Badge variant="secondary" className="mt-1 text-xs">
{condition.unit}
</Badge>
)}
<Settings className="mr-1 h-3 w-3" />
Настроить
</Button>
</DialogTrigger>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-base">
Условия поверки
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1 text-xs text-muted-foreground">
<div>{selectedLab?.name}</div>
<div>
{new Date(selectedDate).toLocaleDateString('ru-RU')}
</div>
</div>
<div className="flex items-center gap-2">
<Input
type="number"
step="0.1"
value={localConditions[key] || ''}
onChange={e => updateCondition(key, e.target.value)}
placeholder={
condition.minValue && condition.maxValue
? `${condition.minValue}-${condition.maxValue}`
: 'Значение'
}
className={`w-20 text-right ${error ? 'border-destructive' : ''}`}
/>
{condition.isRequired && (
<span className="text-sm text-destructive">*</span>
)}
<div className="space-y-2">
{labData.conditionTypes.map(conditionType => (
<div key={conditionType.id} className="space-y-1">
<Label className="text-xs font-medium">
{conditionType.name}
{conditionType.unit && (
<span className="ml-1 font-normal text-muted-foreground">
({conditionType.unit})
</span>
)}
</Label>
<Input
value={editingConditions[conditionType.id] || ''}
onChange={e =>
handleConditionChange(
conditionType.id,
e.target.value
)
}
placeholder="Значение"
className="h-7 text-xs"
/>
</div>
))}
</div>
<div className="flex gap-2">
<Button
onClick={handleSaveConditions}
disabled={isSaving}
size="sm"
className="h-7 flex-1 text-xs"
>
{isSaving ? 'Сохранение...' : 'Сохранить'}
</Button>
<Button
variant="outline"
onClick={() => setEditDialogOpen(false)}
size="sm"
className="h-7 flex-1 text-xs"
>
Отмена
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
<div className="space-y-1">
{labData.conditionTypes.map(conditionType => {
const conditionValue =
dailyCondition!.conditions[conditionType.id]
return (
<div
key={conditionType.id}
className="flex items-center justify-between rounded bg-muted/30 px-2 py-1 text-xs"
>
<span className="truncate font-medium">
{conditionType.name}
</span>
<span className="ml-2 font-mono text-muted-foreground">
{conditionValue || '—'}
{conditionValue && conditionType.unit && (
<span className="ml-1">{conditionType.unit}</span>
)}
</span>
</div>
)
})}
{conditions.conditions.length > config.maxConditions && (
<div className="text-center text-xs text-muted-foreground">
... и ещё{' '}
{conditions.conditions.length - config.maxConditions} условий
</div>
)}
</div>
</div>
</div>
)}
) : (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs text-orange-600">
<AlertCircle className="h-3.5 w-3.5" />
<span className="font-medium">Данные не внесены</span>
</div>
{/* Ошибки валидации */}
{validationErrors.length > 0 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<div className="space-y-1">
{validationErrors.map((error, index) => (
<div key={index} className="text-sm">
{error.message}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={() => handleOpenEditDialog(true)}
disabled={isSaving}
size="sm"
className="h-6 w-full text-xs"
variant="outline"
>
<Plus className="mr-1.5 h-3 w-3" />
Внести данные
</Button>
</DialogTrigger>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-base">
Условия поверки
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1 text-xs text-muted-foreground">
<div>{selectedLab?.name}</div>
<div>
{new Date(selectedDate).toLocaleDateString('ru-RU')}
</div>
</div>
<div className="space-y-2">
{labData.conditionTypes.map(conditionType => (
<div key={conditionType.id} className="space-y-1">
<Label className="text-xs font-medium">
{conditionType.name}
{conditionType.unit && (
<span className="ml-1 font-normal text-muted-foreground">
({conditionType.unit})
</span>
)}
</Label>
<Input
value={editingConditions[conditionType.id] || ''}
onChange={e =>
handleConditionChange(
conditionType.id,
e.target.value
)
}
placeholder="Значение"
className="h-7 text-xs"
/>
</div>
))}
</div>
<div className="flex gap-2">
<Button
onClick={handleSaveConditions}
disabled={isSaving}
size="sm"
className="h-7 flex-1 text-xs"
>
{isSaving ? 'Сохранение...' : 'Сохранить'}
</Button>
<Button
variant="outline"
onClick={() => setEditDialogOpen(false)}
size="sm"
className="h-7 flex-1 text-xs"
>
Отмена
</Button>
</div>
</div>
))}
</div>
</AlertDescription>
</Alert>
)}
{/* Статус */}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
{config.autoSave && hasUnsavedChanges && (
<div className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Автосохранение...
</div>
)}
{config.autoSave &&
!hasUnsavedChanges &&
selectedLaboratoryId &&
selectedDate && (
<div className="flex items-center gap-1 text-green-600">
<CheckCircle2 className="h-3 w-3" />
Сохранено
</div>
)}
</div>
{selectedLaboratory && <div>{selectedLaboratory.name}</div>}
</DialogContent>
</Dialog>
</div>
)}
</div>
{/* Подсказка для пустого состояния */}
{!selectedLaboratoryId && (
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center text-sm text-muted-foreground">
<Building2 className="mx-auto mb-2 h-6 w-6 opacity-50" />
<p>Выберите лабораторию для настройки условий поверки</p>
</div>
)}
</CardContent>
</Card>
)

View File

@@ -1,165 +1,129 @@
import {
ConditionType,
DailyConditions,
ElementConditionsData,
CreateDailyConditionInput,
CreateDailyConditionOutput,
DailyCondition,
DailyConditionsFilters,
GetDailyConditionOutput,
GetDailyConditionsOutput,
GetLaboratoriesOutput,
GetLaboratoryOutput,
Laboratory,
SaveConditionsResponse,
VerificationConditionsValue,
UpdateDailyConditionInput,
UpdateDailyConditionOutput,
} from './types'
const BASE_URL = '/api/v1/verification-conditions'
// Базовый URL для API (может быть настроен через переменные окружения)
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
// API для лабораторий
export const laboratoriesApi = {
// Получить все лаборатории
getAll: async (): Promise<Laboratory[]> => {
const response = await fetch(`${BASE_URL}/laboratories`)
const response = await fetch(`${BASE_URL}/laboratories/`)
if (!response.ok) {
throw new Error('Ошибка загрузки лабораторий')
}
return response.json()
const data: GetLaboratoriesOutput = await response.json()
return data.laboratories
},
// Получить лабораторию по ID
getById: async (id: number): Promise<Laboratory> => {
getById: async (id: string): Promise<Laboratory | null> => {
const response = await fetch(`${BASE_URL}/laboratories/${id}`)
if (!response.ok) {
throw new Error('Лаборатория не найдена')
if (response.status === 404) {
return null
}
throw new Error('Ошибка получения лаборатории')
}
return response.json()
},
// Создать лабораторию
create: async (
data: Omit<Laboratory, 'id' | 'createdAt' | 'updatedAt'>
): Promise<Laboratory> => {
const response = await fetch(`${BASE_URL}/laboratories`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error('Ошибка создания лаборатории')
}
return response.json()
},
}
// API для типов условий
export const conditionTypesApi = {
// Получить все типы условий
getAll: async (): Promise<ConditionType[]> => {
const response = await fetch(`${BASE_URL}/condition-types`)
if (!response.ok) {
throw new Error('Ошибка загрузки типов условий')
}
return response.json()
},
// Создать тип условия
create: async (
data: Omit<ConditionType, 'id' | 'createdAt'>
): Promise<ConditionType> => {
const response = await fetch(`${BASE_URL}/condition-types`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error('Ошибка создания типа условия')
}
return response.json()
const data: GetLaboratoryOutput = await response.json()
return data.laboratory
},
}
// API для ежедневных условий
export const dailyConditionsApi = {
// Получить условия по фильтрам
get: async (params: {
laboratoryId?: number
date?: string
dateFrom?: string
dateTo?: string
}): Promise<DailyConditions[]> => {
get: async (
filters: DailyConditionsFilters = {}
): Promise<DailyCondition[]> => {
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value) searchParams.append(key, value.toString())
})
if (filters.laboratory_id) {
searchParams.append('laboratory_id', filters.laboratory_id)
}
if (filters.start_date) {
searchParams.append('start_date', filters.start_date)
}
if (filters.end_date) {
searchParams.append('end_date', filters.end_date)
}
const response = await fetch(`${BASE_URL}/daily-conditions?${searchParams}`)
const url = searchParams.toString()
? `${BASE_URL}/daily-conditions/?${searchParams}`
: `${BASE_URL}/daily-conditions/`
const response = await fetch(url)
if (!response.ok) {
throw new Error('Ошибка загрузки условий')
}
return response.json()
const data: GetDailyConditionsOutput = await response.json()
return data.daily_conditions
},
// Получить условия для элемента
getForElement: async (
laboratoryId: number,
date: string
): Promise<ElementConditionsData> => {
const response = await fetch(
`${BASE_URL}/daily-conditions/for-element?laboratoryId=${laboratoryId}&date=${date}`
)
// Получить конкретное условие по ID
getById: async (id: string): Promise<DailyCondition | null> => {
const response = await fetch(`${BASE_URL}/daily-conditions/${id}`)
if (!response.ok) {
throw new Error('Ошибка загрузки условий для элемента')
}
return response.json()
},
// Сохранить условия из элемента
saveForElement: async (
data: VerificationConditionsValue
): Promise<SaveConditionsResponse> => {
const response = await fetch(
`${BASE_URL}/daily-conditions/save-for-element`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
if (response.status === 404) {
return null
}
)
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Ошибка сохранения условий')
throw new Error('Ошибка получения условия')
}
return response.json()
const data: GetDailyConditionOutput = await response.json()
return data.daily_condition
},
// Создать/обновить условия
save: async (
data: Omit<DailyConditions, 'id' | 'createdAt' | 'updatedAt'>
): Promise<DailyConditions> => {
const response = await fetch(`${BASE_URL}/daily-conditions`, {
// Создать новые условия
create: async (input: CreateDailyConditionInput): Promise<string> => {
const response = await fetch(`${BASE_URL}/daily-conditions/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
})
if (!response.ok) {
const error = await response.json()
const error = await response
.json()
.catch(() => ({ message: 'Ошибка сохранения условий' }))
throw new Error(error.message || 'Ошибка сохранения условий')
}
return response.json()
const data: CreateDailyConditionOutput = await response.json()
return data.id
},
// Обновить условия
update: async (
id: number,
data: Partial<DailyConditions>
): Promise<DailyConditions> => {
id: string,
input: UpdateDailyConditionInput
): Promise<string> => {
const response = await fetch(`${BASE_URL}/daily-conditions/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
})
if (!response.ok) {
throw new Error('Ошибка обновления условий')
}
return response.json()
const data: UpdateDailyConditionOutput = await response.json()
return data.id
},
// Удалить условия
delete: async (id: number): Promise<void> => {
delete: async (id: string): Promise<void> => {
const response = await fetch(`${BASE_URL}/daily-conditions/${id}`, {
method: 'DELETE',
})
@@ -169,12 +133,83 @@ export const dailyConditionsApi = {
},
}
// Комбинированный API для работы с элементом
export const elementApi = {
// Получить лабораторию с её типами условий
getLaboratoryData: async (
laboratoryId: string
): Promise<{
laboratory: Laboratory
conditionTypes: ConditionType[]
}> => {
const laboratory = await laboratoriesApi.getById(laboratoryId)
if (!laboratory) {
throw new Error('Лаборатория не найдена')
}
return {
laboratory,
conditionTypes: laboratory.condition_types,
}
},
// Получить ежедневные условия для конкретной лаборатории и даты
getDailyConditions: async (
laboratoryId: string,
date: string
): Promise<DailyCondition | null> => {
const conditions = await dailyConditionsApi.get({
laboratory_id: laboratoryId,
start_date: date,
end_date: date,
})
return conditions.length > 0 ? conditions[0] : null
},
// Сохранить или обновить условия из элемента
saveElementConditions: async (data: {
laboratoryId: string
date: string
conditions: Record<string, any>
existingConditionId?: string
}): Promise<DailyCondition> => {
const input: CreateDailyConditionInput | UpdateDailyConditionInput = {
laboratory_id: data.laboratoryId,
measurement_date: data.date,
conditions: data.conditions,
}
let conditionId: string
if (data.existingConditionId) {
// Обновляем существующие условия
conditionId = await dailyConditionsApi.update(
data.existingConditionId,
input as UpdateDailyConditionInput
)
} else {
// Создаем новые условия
conditionId = await dailyConditionsApi.create(
input as CreateDailyConditionInput
)
}
// Получаем обновленные данные
const savedCondition = await dailyConditionsApi.getById(conditionId)
if (!savedCondition) {
throw new Error('Ошибка получения сохраненных условий')
}
return savedCondition
},
}
// Вспомогательные функции
export const verificationConditionsUtils = {
// Валидация значения условия
validateConditionValue: (
value: any,
dataType: string,
dataType: string = 'string',
minValue?: number,
maxValue?: number
): { isValid: boolean; error?: string } => {
@@ -201,11 +236,23 @@ export const verificationConditionsUtils = {
return unit ? `${value} ${unit}` : value.toString()
},
// Получение ключа условия для хранения в conditions JSON
getConditionKey: (conditionTypeName: string): string => {
// Получение ключа условия для хранения в conditions JSON (обычно используется ID типа условия)
getConditionKey: (conditionTypeId: string): string => {
return conditionTypeId
},
// Генерация ключа на основе названия условия (для обратной совместимости)
generateConditionKey: (conditionTypeName: string): string => {
return conditionTypeName
.toLowerCase()
.replace(/\s+/g, '_')
.replace(/[^\w]/g, '')
},
// Проверка, является ли строка валидным UUID
isValidUUID: (str: string): boolean => {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
return uuidRegex.test(str)
},
}

View File

@@ -1,6 +1,6 @@
import { ElementDefinition } from '@/entitiy/element/model/interface'
import { CellTarget } from '@/type/template'
import { Gauge } from 'lucide-react'
import { Building2 } from 'lucide-react'
import {
VerificationConditionsConfig,
VerificationConditionsValue,
@@ -9,70 +9,68 @@ import { VerificationConditionsEditor } from './VerificationConditionsEditor'
import { VerificationConditionsPreview } from './VerificationConditionsPreview'
import { VerificationConditionsRender } from './VerificationConditionsRender'
export const verificationConditionsDefinition: ElementDefinition<
VerificationConditionsConfig,
export const verificationConditionsElementDefinition: ElementDefinition<
VerificationConditionsConfig & { targetCells?: CellTarget[] },
VerificationConditionsValue
> = {
type: 'verification_conditions',
label: 'Условия поверки',
icon: <Gauge className="h-4 w-4" />,
icon: <Building2 className="h-4 w-4" />,
version: 1,
defaultConfig: {
targetCells: [],
selectedLaboratoryId: undefined,
selectedDate: undefined,
conditionMappings: [],
showUnits: true,
targetCells: [],
},
// Компоненты
Editor: VerificationConditionsEditor,
Preview: VerificationConditionsPreview,
Render: VerificationConditionsRender,
mapToCells: config => {
// Возвращаем настроенные пользователем целевые ячейки
return config.targetCells.map(cell => ({
sheet: cell.sheet,
cell: cell.cell,
}))
// Получение ячеек для записи данных на основе маппинга условий
mapToCells: (config): CellTarget[] => {
// Возвращаем только те targetCells, которые замаплены к условиям
if (!config.conditionMappings || !config.targetCells) {
return []
}
return config.conditionMappings
.map(mapping => config.targetCells?.[mapping.cellIndex])
.filter(Boolean) as CellTarget[]
},
mapToCellValues: (config, value) => {
if (!value) return []
// Получение значения для записи в ячейки на основе реального маппинга
mapToCellValues: (
config,
value: VerificationConditionsValue
): Array<{ target: CellTarget; value: any }> => {
if (!config.conditionMappings || !config.targetCells || !value.conditions) {
return []
}
const targetCells = config.targetCells || []
const mappings = config.conditionMappings || []
if (targetCells.length === 0) return []
const results: Array<{ target: CellTarget; value: any }> = []
// Записываем данные в ячейки согласно настроенным привязкам
mappings.forEach(mapping => {
const targetCell = targetCells[mapping.cellIndex]
if (!targetCell) return
let cellValue: any = ''
switch (mapping.conditionKey) {
case 'laboratory':
cellValue = `Лаборатория ${value.laboratoryId}` // Здесь можно подставить реальное название
break
case 'date':
cellValue = value.date
break
default:
// Это условие из лаборатории
cellValue = value.conditions[mapping.conditionKey] || ''
break
}
results.push({
target: {
sheet: targetCell.sheet,
cell: targetCell.cell,
},
value: cellValue,
return config.conditionMappings
.filter(mapping => {
const conditionValue = value.conditions[mapping.conditionKey]
// Записываем только непустые значения
return (
conditionValue !== undefined &&
conditionValue !== null &&
String(conditionValue).trim() !== ''
)
})
})
.map(mapping => {
const targetCell = config.targetCells?.[mapping.cellIndex]
if (!targetCell) return null
return results
return {
target: targetCell,
value: value.conditions[mapping.conditionKey],
}
})
.filter(Boolean) as Array<{ target: CellTarget; value: any }>
},
}

View File

@@ -1,13 +1,8 @@
import { useCallback, useEffect, useState } from 'react'
import { mockApiHelpers, mockLaboratories } from './mockData'
import {
ElementConditionsData,
Laboratory,
ValidationError,
VerificationConditionsValue,
} from './types'
import { dailyConditionsApi, elementApi, laboratoriesApi } from './api'
import { ConditionType, DailyCondition, Laboratory } from './types'
// Простой хук для загрузки лабораторий (используем моковые данные)
// Хук для загрузки лабораторий
export function useLaboratories() {
const [data, setData] = useState<Laboratory[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -17,9 +12,9 @@ export function useLaboratories() {
const loadData = async () => {
try {
setIsLoading(true)
await mockApiHelpers.delay(300) // Симуляция загрузки
setData(mockLaboratories)
setIsError(false)
const laboratories = await laboratoriesApi.getAll()
setData(laboratories)
} catch (error) {
setIsError(true)
console.error('Ошибка загрузки лабораторий:', error)
@@ -34,25 +29,127 @@ export function useLaboratories() {
return { data, isLoading, isError }
}
// Хук для загрузки конкретной лаборатории
export function useLaboratory(id: number | undefined) {
const [data, setData] = useState<Laboratory | undefined>()
// Хук для работы с ежедневными условиями
export function useDailyConditions(laboratoryId?: string, date?: string) {
const [data, setData] = useState<DailyCondition | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
if (!id) {
setData(undefined)
if (!laboratoryId || !date) {
setData(null)
return
}
const loadData = async () => {
try {
setIsLoading(true)
await mockApiHelpers.delay(200)
const laboratory = mockApiHelpers.getLaboratoryById(id)
setIsError(false)
const conditions = await elementApi.getDailyConditions(
laboratoryId,
date
)
setData(conditions)
} catch (error) {
setIsError(true)
console.error('Ошибка загрузки ежедневных условий:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [laboratoryId, date])
const saveConditions = useCallback(
async (conditions: Record<string, any>) => {
if (!laboratoryId || !date) {
throw new Error('Не указаны лаборатория или дата')
}
try {
setIsSaving(true)
const savedCondition = await elementApi.saveElementConditions({
laboratoryId,
date,
conditions,
existingConditionId: data?.id,
})
setData(savedCondition)
return savedCondition
} catch (error) {
console.error('Ошибка сохранения условий:', error)
throw error
} finally {
setIsSaving(false)
}
},
[laboratoryId, date, data?.id]
)
return {
data,
isLoading,
isError,
isSaving,
saveConditions,
}
}
// Хук для получения данных лаборатории с типами условий
export function useLaboratoryConditions(laboratoryId?: string) {
const [data, setData] = useState<{
laboratory: Laboratory
conditionTypes: ConditionType[]
} | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
useEffect(() => {
if (!laboratoryId) {
setData(null)
return
}
const loadData = async () => {
try {
setIsLoading(true)
setIsError(false)
const result = await elementApi.getLaboratoryData(laboratoryId)
setData(result)
} catch (error) {
setIsError(true)
console.error('Ошибка загрузки условий лаборатории:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [laboratoryId])
return { data, isLoading, isError }
}
// Хук для получения конкретной лаборатории
export function useLaboratory(laboratoryId?: string) {
const [data, setData] = useState<Laboratory | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
useEffect(() => {
if (!laboratoryId) {
setData(null)
return
}
const loadData = async () => {
try {
setIsLoading(true)
setIsError(false)
const laboratory = await laboratoriesApi.getById(laboratoryId)
setData(laboratory)
setIsError(!laboratory)
} catch (error) {
setIsError(true)
console.error('Ошибка загрузки лаборатории:', error)
@@ -62,370 +159,60 @@ export function useLaboratory(id: number | undefined) {
}
loadData()
}, [id])
}, [laboratoryId])
return { data, isLoading, isError }
}
// Хук для загрузки условий для элемента
export function useElementConditions(
laboratoryId: number | undefined,
date: string | undefined
) {
const [data, setData] = useState<ElementConditionsData | undefined>()
// Хук для управления ежедневными условиями с расширенным функционалом
export function useDailyConditionsManager() {
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
useEffect(() => {
if (!laboratoryId || !date) {
setData(undefined)
return
}
const loadData = async () => {
const loadConditions = useCallback(
async (filters: {
laboratoryId?: string
startDate?: string
endDate?: string
}) => {
try {
setIsLoading(true)
await mockApiHelpers.delay(400)
const conditions = mockApiHelpers.getElementConditions(
laboratoryId,
date
)
setData(conditions)
setIsError(!conditions)
setIsError(false)
const conditions = await dailyConditionsApi.get({
laboratory_id: filters.laboratoryId,
start_date: filters.startDate,
end_date: filters.endDate,
})
return conditions
} catch (error) {
setIsError(true)
console.error('Ошибка загрузки условий:', error)
throw error
} finally {
setIsLoading(false)
}
}
loadData()
}, [laboratoryId, date])
return { data, isLoading, isError }
}
// Хук для сохранения условий
export function useSaveConditions() {
const [isPending, setIsPending] = useState(false)
const [isError, setIsError] = useState(false)
const [error, setError] = useState<Error | null>(null)
const mutateAsync = useCallback(
async (value: VerificationConditionsValue) => {
try {
setIsPending(true)
setIsError(false)
setError(null)
await mockApiHelpers.delay(800) // Симуляция сохранения
// Симуляция возможной ошибки (5% вероятность)
if (Math.random() < 0.05) {
throw new Error('Ошибка сети при сохранении')
}
console.log('Сохранены условия:', value)
return {
id: Math.floor(Math.random() * 1000),
success: true,
message: 'Условия успешно сохранены',
}
} catch (err) {
const error =
err instanceof Error ? err : new Error('Неизвестная ошибка')
setIsError(true)
setError(error)
throw error
} finally {
setIsPending(false)
}
},
[]
)
return { mutateAsync, isPending, isError, error }
}
// Вспомогательная функция для валидации
const validateConditionValue = (
value: any,
dataType: string,
minValue?: number,
maxValue?: number
): { isValid: boolean; error?: string } => {
if (dataType === 'number') {
const numValue = parseFloat(value)
if (isNaN(numValue)) {
return { isValid: false, error: 'Должно быть числом' }
const deleteConditions = useCallback(async (conditionId: string) => {
try {
setIsLoading(true)
setIsError(false)
await dailyConditionsApi.delete(conditionId)
} catch (error) {
setIsError(true)
console.error('Ошибка удаления условий:', error)
throw error
} finally {
setIsLoading(false)
}
if (minValue !== undefined && numValue < minValue) {
return { isValid: false, error: `Минимальное значение: ${minValue}` }
}
if (maxValue !== undefined && numValue > maxValue) {
return { isValid: false, error: `Максимальное значение: ${maxValue}` }
}
}
return { isValid: true }
}
// Вспомогательная функция для получения ключа условия
const getConditionKey = (conditionTypeName: string): string => {
return conditionTypeName
.toLowerCase()
.replace(/\s+/g, '_')
.replace(/[^\w]/g, '')
}
// Основной хук для управления состоянием элемента условий поверки
export function useVerificationConditionsElement(
initialConfig: {
selectedLaboratoryId?: number
selectedDate?: string
autoSave?: boolean
} = {}
) {
const [selectedLaboratoryId, setSelectedLaboratoryId] = useState<
number | undefined
>(initialConfig.selectedLaboratoryId)
const [selectedDate, setSelectedDate] = useState<string | undefined>(
initialConfig.selectedDate || new Date().toISOString().split('T')[0]
)
const [localConditions, setLocalConditions] = useState<Record<string, any>>(
{}
)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [validationErrors, setValidationErrors] = useState<ValidationError[]>(
[]
)
// Загружаем данные
const laboratoriesQuery = useLaboratories()
const elementConditionsQuery = useElementConditions(
selectedLaboratoryId,
selectedDate
)
const saveConditionsMutation = useSaveConditions()
// Получаем выбранную лабораторию
const selectedLaboratory = laboratoriesQuery.data?.find(
lab => lab.id === selectedLaboratoryId
)
// Синхронизируем локальные условия с загруженными данными
useEffect(() => {
if (elementConditionsQuery.data && !hasUnsavedChanges) {
const conditions: Record<string, any> = {}
elementConditionsQuery.data.conditions.forEach(cond => {
const key = getConditionKey(cond.typeName)
conditions[key] = cond.value
})
setLocalConditions(conditions)
}
}, [elementConditionsQuery.data, hasUnsavedChanges])
// Валидация условий
const validateConditions = useCallback(
(conditions: Record<string, any>): ValidationError[] => {
const errors: ValidationError[] = []
if (!elementConditionsQuery.data) return errors
elementConditionsQuery.data.conditions.forEach(conditionDef => {
const key = getConditionKey(conditionDef.typeName)
const value = conditions[key]
// Проверка обязательных полей
if (
conditionDef.isRequired &&
(value === undefined || value === null || value === '')
) {
errors.push({
field: key,
message: `${conditionDef.typeName} обязательно для заполнения`,
})
return
}
// Валидация значения
if (value !== undefined && value !== null && value !== '') {
const validation = validateConditionValue(
value,
'number',
conditionDef.minValue,
conditionDef.maxValue
)
if (!validation.isValid) {
errors.push({
field: key,
message: validation.error!,
value,
min: conditionDef.minValue,
max: conditionDef.maxValue,
})
}
}
})
return errors
},
[elementConditionsQuery.data]
)
// Обновление значения условия
const updateCondition = useCallback(
(key: string, value: any) => {
setLocalConditions(prev => ({
...prev,
[key]: value,
}))
setHasUnsavedChanges(true)
// Валидируем новые условия
const newConditions = { ...localConditions, [key]: value }
const errors = validateConditions(newConditions)
setValidationErrors(errors)
},
[localConditions, validateConditions]
)
// Смена лаборатории
const changeLaboratory = useCallback(
(laboratoryId: number) => {
if (hasUnsavedChanges) {
const confirmed = window.confirm(
'У вас есть несохранённые изменения. Продолжить без сохранения?'
)
if (!confirmed) return
}
setSelectedLaboratoryId(laboratoryId)
setLocalConditions({})
setHasUnsavedChanges(false)
setValidationErrors([])
},
[hasUnsavedChanges]
)
// Смена даты
const changeDate = useCallback(
(date: string) => {
if (hasUnsavedChanges) {
const confirmed = window.confirm(
'У вас есть несохранённые изменения. Продолжить без сохранения?'
)
if (!confirmed) return
}
setSelectedDate(date)
setLocalConditions({})
setHasUnsavedChanges(false)
setValidationErrors([])
},
[hasUnsavedChanges]
)
// Сохранение условий
const saveConditions = useCallback(async () => {
if (!selectedLaboratoryId || !selectedDate) {
throw new Error('Не выбрана лаборатория или дата')
}
const errors = validateConditions(localConditions)
if (errors.length > 0) {
setValidationErrors(errors)
throw new Error('Есть ошибки валидации')
}
const value: VerificationConditionsValue = {
laboratoryId: selectedLaboratoryId,
date: selectedDate,
conditions: localConditions,
}
const result = await saveConditionsMutation.mutateAsync(value)
setHasUnsavedChanges(false)
setValidationErrors([])
return result
}, [
selectedLaboratoryId,
selectedDate,
localConditions,
validateConditions,
saveConditionsMutation,
])
// Автосохранение
useEffect(() => {
if (
initialConfig.autoSave &&
hasUnsavedChanges &&
validationErrors.length === 0
) {
const timeoutId = setTimeout(() => {
saveConditions().catch(console.error)
}, 2000) // Автосохранение через 2 секунды
return () => clearTimeout(timeoutId)
}
}, [
initialConfig.autoSave,
hasUnsavedChanges,
validationErrors.length,
saveConditions,
])
// Получение текущего значения для компонента
const getCurrentValue = useCallback(():
| VerificationConditionsValue
| undefined => {
if (!selectedLaboratoryId || !selectedDate) return undefined
return {
laboratoryId: selectedLaboratoryId,
date: selectedDate,
conditions: localConditions,
}
}, [selectedLaboratoryId, selectedDate, localConditions])
// Форматирование значения с единицей измерения
const formatValueWithUnit = useCallback(
(value: any, unit?: string): string => {
if (value === undefined || value === null || value === '') {
return '-'
}
return unit ? `${value} ${unit}` : value.toString()
},
[]
)
}, [])
return {
// Данные
laboratories: laboratoriesQuery.data || [],
selectedLaboratory,
selectedLaboratoryId,
selectedDate,
conditions: elementConditionsQuery.data,
localConditions,
// Состояние
isLoading: laboratoriesQuery.isLoading || elementConditionsQuery.isLoading,
isError: laboratoriesQuery.isError || elementConditionsQuery.isError,
isSaving: saveConditionsMutation.isPending,
hasUnsavedChanges,
validationErrors,
// Действия
changeLaboratory,
changeDate,
updateCondition,
saveConditions,
getCurrentValue,
// Вспомогательные функции
formatValueWithUnit,
getConditionKey,
isLoading,
isError,
loadConditions,
deleteConditions,
}
}

View File

@@ -1,4 +1,4 @@
export { verificationConditionsDefinition } from './definition'
export { verificationConditionsElementDefinition as verificationConditionsDefinition } from './definition'
export { VerificationConditionsEditor } from './VerificationConditionsEditor'
export { VerificationConditionsPreview } from './VerificationConditionsPreview'
export { VerificationConditionsRender } from './VerificationConditionsRender'

View File

@@ -1,280 +1,2 @@
import { ConditionType, Laboratory } from './types'
// Моковые типы условий (из БД схемы)
export const mockConditionTypes: ConditionType[] = [
{
id: 1,
name: 'Температура',
unit: '°C',
dataType: 'number',
description: 'Температура окружающей среды',
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 2,
name: 'Влажность',
unit: '%',
dataType: 'number',
description: 'Относительная влажность воздуха',
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 3,
name: 'Атмосферное давление',
unit: 'кПа',
dataType: 'number',
description: 'Атмосферное давление',
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 4,
name: 'Давление среды',
unit: 'МПа',
dataType: 'number',
description: 'Давление рабочей среды',
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 5,
name: 'Напряжение сети',
unit: 'В',
dataType: 'number',
description: 'Напряжение питающей сети',
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 6,
name: 'Частота сети',
unit: 'Гц',
dataType: 'number',
description: 'Частота питающей сети',
createdAt: '2024-01-01T00:00:00Z',
},
]
// Моковые лаборатории с полными данными условий
export const mockLaboratories: Laboratory[] = [
{
id: 1,
name: 'Лаборатория температуры',
code: 'TEMP_LAB',
description: 'Лаборатория поверки температурных приборов',
address: 'Корпус А, комната 101',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
conditionTypes: [
{
id: 1,
laboratoryId: 1,
conditionTypeId: 1,
conditionType: mockConditionTypes[0], // Температура
isRequired: true,
defaultValue: '20',
minValue: 15,
maxValue: 30,
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 2,
laboratoryId: 1,
conditionTypeId: 2,
conditionType: mockConditionTypes[1], // Влажность
isRequired: true,
defaultValue: '50',
minValue: 30,
maxValue: 80,
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 3,
laboratoryId: 1,
conditionTypeId: 3,
conditionType: mockConditionTypes[2], // Атмосферное давление
isRequired: false,
defaultValue: '101.3',
minValue: 80,
maxValue: 120,
createdAt: '2024-01-01T00:00:00Z',
},
],
},
{
id: 2,
name: 'Лаборатория давления',
code: 'PRESS_LAB',
description: 'Лаборатория поверки приборов давления',
address: 'Корпус Б, комната 205',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
conditionTypes: [
{
id: 4,
laboratoryId: 2,
conditionTypeId: 1,
conditionType: mockConditionTypes[0], // Температура
isRequired: true,
defaultValue: '23',
minValue: 18,
maxValue: 28,
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 5,
laboratoryId: 2,
conditionTypeId: 2,
conditionType: mockConditionTypes[1], // Влажность
isRequired: false,
defaultValue: '45',
minValue: 20,
maxValue: 70,
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 6,
laboratoryId: 2,
conditionTypeId: 3,
conditionType: mockConditionTypes[2], // Атмосферное давление
isRequired: true,
defaultValue: '101.3',
minValue: 90,
maxValue: 110,
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 7,
laboratoryId: 2,
conditionTypeId: 4,
conditionType: mockConditionTypes[3], // Давление среды
isRequired: true,
defaultValue: '0.1',
minValue: 0,
maxValue: 100,
createdAt: '2024-01-01T00:00:00Z',
},
],
},
{
id: 3,
name: 'Электрическая лаборатория',
code: 'ELEC_LAB',
description: 'Лаборатория поверки электрических приборов',
address: 'Корпус В, комната 310',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
conditionTypes: [
{
id: 8,
laboratoryId: 3,
conditionTypeId: 1,
conditionType: mockConditionTypes[0], // Температура
isRequired: true,
defaultValue: '22',
minValue: 20,
maxValue: 25,
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 9,
laboratoryId: 3,
conditionTypeId: 2,
conditionType: mockConditionTypes[1], // Влажность
isRequired: true,
defaultValue: '60',
minValue: 40,
maxValue: 70,
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 10,
laboratoryId: 3,
conditionTypeId: 5,
conditionType: mockConditionTypes[4], // Напряжение сети
isRequired: true,
defaultValue: '220',
minValue: 198,
maxValue: 242,
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 11,
laboratoryId: 3,
conditionTypeId: 6,
conditionType: mockConditionTypes[5], // Частота сети
isRequired: true,
defaultValue: '50',
minValue: 49.5,
maxValue: 50.5,
createdAt: '2024-01-01T00:00:00Z',
},
],
},
]
// Функция для получения всех доступных условий (для выбора в элементе)
export const getAllAvailableConditions = () => {
return [
{
key: 'laboratory',
name: 'Название лаборатории',
unit: '',
dataType: 'string' as const,
},
{ key: 'date', name: 'Дата', unit: '', dataType: 'string' as const },
...mockConditionTypes.map(type => ({
key: type.name.toLowerCase().replace(/\s+/g, '_').replace(/[^\w]/g, ''),
name: type.name,
unit: type.unit || '',
dataType: type.dataType,
})),
]
}
// Упрощенный API helper
export const mockApiHelpers = {
// Получить все лаборатории
getLaboratories: async (): Promise<Laboratory[]> => {
await new Promise(resolve => setTimeout(resolve, 300))
return mockLaboratories
},
// Получить лабораторию по ID
getLaboratoryById: (id: number): Laboratory | undefined => {
return mockLaboratories.find(lab => lab.id === id)
},
// Получить все доступные условия
getAllConditions: () => getAllAvailableConditions(),
// Получить условия конкретной лаборатории
getLaboratoryConditions: (laboratoryId: number) => {
const lab = mockLaboratories.find(l => l.id === laboratoryId)
if (!lab) return []
return [
{
key: 'laboratory',
name: 'Название лаборатории',
unit: '',
dataType: 'string' as const,
},
{ key: 'date', name: 'Дата', unit: '', dataType: 'string' as const },
...(lab.conditionTypes?.map(ct => ({
key:
ct.conditionType?.name
.toLowerCase()
.replace(/\s+/g, '_')
.replace(/[^\w]/g, '') || `condition_${ct.id}`,
name: ct.conditionType?.name || `Условие ${ct.id}`,
unit: ct.conditionType?.unit || '',
dataType: ct.conditionType?.dataType || ('string' as const),
isRequired: ct.isRequired,
defaultValue: ct.defaultValue,
minValue: ct.minValue,
maxValue: ct.maxValue,
})) || []),
]
},
// Симуляция задержки API
delay: (ms: number = 500) => new Promise(resolve => setTimeout(resolve, ms)),
}
// Флаг для переключения между mock и реальными данными
export const USE_MOCK_DATA = false

View File

@@ -1,109 +1,101 @@
// Типы для элемента "Условия поверки"
// Типы для элемента "Условия поверки" на основе новой API схемы
export interface Laboratory {
id: number
name: string
code: string
description?: string
address?: string
conditionTypes?: LaboratoryConditionType[]
createdAt: string
updatedAt: string
}
export interface ConditionType {
id: number
name: string
unit?: string
dataType: 'number' | 'string' | 'boolean'
description?: string
createdAt: string
}
export interface LaboratoryConditionType {
id: number
laboratoryId: number
conditionTypeId: number
conditionType?: ConditionType
isRequired: boolean
defaultValue?: string
minValue?: number
maxValue?: number
createdAt: string
}
export interface DailyConditions {
id: number
laboratoryId: number
laboratory?: Laboratory
date: string // YYYY-MM-DD
conditions: Record<string, any> // JSON объект с условиями
createdAt: string
updatedAt: string
createdBy?: string
}
export interface ConditionValue {
typeId: number
typeName: string
unit?: string
value: any
isRequired: boolean
minValue?: number
maxValue?: number
}
export interface ElementConditionsData {
laboratoryId: number
laboratoryName: string
date: string
conditions: ConditionValue[]
hasUnsavedChanges: boolean
}
// Привязка условия к ячейке
export interface ConditionCellMapping {
cellIndex: number // Индекс целевой ячейки
conditionKey: string // Ключ условия ('laboratory', 'date', 'temperature', etc.)
conditionName: string // Название для отображения
// Маппинг условия в ячейку
export interface ConditionMapping {
conditionKey: string // ключ условия (temperature, humidity, etc.)
conditionName: string // название условия для отображения
cellIndex: number // индекс целевой ячейки
}
// Конфигурация элемента
export interface VerificationConditionsConfig {
selectedLaboratoryId?: number // Выбранная лаборатория
targetCells: Array<{
sheet: string
cell: string
}> // Целевые ячейки из основного редактора элементов
conditionMappings: ConditionCellMapping[] // Привязка условий к ячейкам
// Выбранная лаборатория (UUID)
selectedLaboratoryId?: string
// Выбранная дата
selectedDate?: string
// Маппинг условий в ячейки
conditionMappings?: ConditionMapping[]
// Показывать ли единицы измерения
showUnits?: boolean
}
// Значение элемента
export interface VerificationConditionsValue {
laboratoryId: number
date: string
conditions: Record<string, any> // Значения условий {temperature: 23.5, humidity: 45.2}
date: string // дата в формате YYYY-MM-DD
conditions: Record<string, string> // условия поверки {temperature: "20", humidity: "50", ...}
}
// Ответы API
export interface ApiResponse<T> {
success: boolean
data?: T
message?: string
error?: string
// Тип условия из API
export interface ConditionType {
id: string
name: string
unit: string
created_at: string
updated_at: string
deleted_at: string | null
}
export interface SaveConditionsResponse {
id: number
success: boolean
message: string
// Лаборатория из API
export interface Laboratory {
id: string
name: string
condition_types: ConditionType[]
created_at: string
updated_at: string
deleted_at: string | null
}
// Ошибки валидации
export interface ValidationError {
field: string
message: string
value?: any
min?: number
max?: number
// Ежедневное условие из API
export interface DailyCondition {
id: string
laboratory_id: string
measurement_date: string
conditions: Record<string, any>
created_at: string
updated_at: string
deleted_at: string | null
}
// Типы для API запросов/ответов
export interface GetLaboratoriesOutput {
laboratories: Laboratory[]
}
export interface GetLaboratoryOutput {
laboratory: Laboratory | null
}
export interface CreateDailyConditionInput {
laboratory_id: string
measurement_date: string
conditions: Record<string, any>
}
export interface CreateDailyConditionOutput {
id: string
}
export interface UpdateDailyConditionInput {
laboratory_id?: string | null
measurement_date?: string | null
conditions?: Record<string, any> | null
}
export interface UpdateDailyConditionOutput {
id: string
}
export interface GetDailyConditionsOutput {
daily_conditions: DailyCondition[]
}
export interface GetDailyConditionOutput {
daily_condition: DailyCondition | null
}
// Параметры фильтрации для получения ежедневных условий
export interface DailyConditionsFilters {
laboratory_id?: string
start_date?: string
end_date?: string
}

View 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 }

View File

@@ -6,18 +6,10 @@ import {
SelectTrigger,
SelectValue,
} from '@/component/ui/select'
import { CellTarget } from '@/type/template'
import { Info, Settings } from 'lucide-react'
import { useState } from 'react'
import { StandardsSelector } from './StandardsSelector'
interface StandardsConfig {
sheet: string // Лист Excel
startCell: string // Первая ячейка (например "A10")
maxItems: number // Обычно 7
targetCells: CellTarget[]
selectedStandards?: string[] // Выбранные эталоны (ID)
}
import { StandardsConfig } from './definition'
interface StandardsEditorProps {
config: StandardsConfig
@@ -32,8 +24,6 @@ export const StandardsEditor: React.FC<StandardsEditorProps> = ({
// Обеспечиваем значения по умолчанию
const safeConfig = {
sheet: config.sheet || 'L',
startCell: config.startCell || 'A1',
maxItems: config.maxItems || 7,
targetCells: config.targetCells || [],
selectedStandards: config.selectedStandards || [],

View File

@@ -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>
)
}

View File

@@ -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] || '',
}))
},
}

View File

@@ -1,11 +1,10 @@
import { textDefinition } from '@/component/BasicElements'
import { calibrationConditionsDefinition } from '@/component/BasicElements/CalibrationConditionsElement'
import { numberDefinition } from '@/component/BasicElements/NumberElement'
import { selectDefinition } from '@/component/BasicElements/SelectElement'
import { standardsDefinition } from '@/component/StandardsElement/definition'
import { verificationConditionsDefinition } from '@/component/VerificationConditionsElement/definition'
import { verificationConditionsElementDefinition as verificationConditionsDefinition } from '@/component/VerificationConditionsElement/definition'
import { buttonGroupDefinition } from '@/entitiy/element/model/implementations/ButtonGroup'
import { dateDefinition } from '@/entitiy/element/model/implementations/DateElement'
import { standardsDefinition } from '@/entitiy/element/model/implementations/StandardsElement/definition'
import { CellTarget } from '@/type/template'
import { ReactNode } from 'react'
@@ -67,7 +66,6 @@ export const elementDefinitions = {
number: numberDefinition,
date: dateDefinition,
standards: standardsDefinition,
calibration_conditions: calibrationConditionsDefinition,
verification_conditions: verificationConditionsDefinition,
button_group: buttonGroupDefinition,
} as const
@@ -80,7 +78,6 @@ export function initializeElementRegistry() {
registerElement('number', numberDefinition)
registerElement('date', dateDefinition)
registerElement('button_group', buttonGroupDefinition)
registerElement('calibration_conditions', calibrationConditionsDefinition)
registerElement('standards', standardsDefinition)
registerElement('verification_conditions', verificationConditionsDefinition)
}

View File

@@ -1,14 +1,3 @@
import {
useCreateStandard,
useDeleteStandard,
useStandards,
useUpdateStandard,
} from '@/component/StandardsElement/hooks'
import {
CreateStandardInput,
PatchStandardInput,
Standard,
} from '@/component/StandardsElement/types'
import { Badge } from '@/component/ui/badge'
import { Button } from '@/component/ui/button'
import {
@@ -19,6 +8,17 @@ import {
} from '@/component/ui/dialog'
import { Input } from '@/component/ui/input'
import { Label } from '@/component/ui/label'
import {
useCreateStandard,
useDeleteStandard,
useStandards,
useUpdateStandard,
} from '@/entitiy/element/model/implementations/StandardsElement/hooks'
import {
CreateStandardInput,
PatchStandardInput,
Standard,
} from '@/entitiy/element/model/implementations/StandardsElement/types'
import {
Calendar,
Edit,

View File

@@ -1,57 +1,57 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: ['class'],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
chart: {
1: 'hsl(var(--chart-1))',
2: 'hsl(var(--chart-2))',
3: 'hsl(var(--chart-3))',
4: 'hsl(var(--chart-4))',
5: 'hsl(var(--chart-5))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [require("tailwindcss-animate")],
plugins: [require('tailwindcss-animate')],
}

View File

@@ -12,6 +12,11 @@
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6"
integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==
"@date-fns/tz@1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@date-fns/tz/-/tz-1.2.0.tgz#81cb3211693830babaf3b96aff51607e143030a6"
integrity sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==
"@dnd-kit/accessibility@^3.1.1":
version "3.1.1"
resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz#3b4202bd6bb370a0730f6734867785919beac6af"
@@ -1639,6 +1644,16 @@ data-view-byte-offset@^1.0.1:
es-errors "^1.3.0"
is-data-view "^1.0.1"
date-fns-jalali@4.1.0-0:
version "4.1.0-0"
resolved "https://registry.yarnpkg.com/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz#9c7fb286004fab267a300d3e9f1ada9f10b4b6b0"
integrity sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==
date-fns@4.1.0, date-fns@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14"
integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==
debug@^3.2.7:
version "3.2.7"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
@@ -3207,6 +3222,15 @@ react-beautiful-dnd@^13.1.1:
redux "^4.0.4"
use-memo-one "^1.1.1"
react-day-picker@^9.8.0:
version "9.8.0"
resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-9.8.0.tgz#b16367fb26f3a967154e0e0f141297bd1f0a6e6b"
integrity sha512-E0yhhg7R+pdgbl/2toTb0xBhsEAtmAx1l7qjIWYfcxOy8w4rTSVfbtBoSzVVhPwKP/5E9iL38LivzoE3AQDhCQ==
dependencies:
"@date-fns/tz" "1.2.0"
date-fns "4.1.0"
date-fns-jalali "4.1.0-0"
react-dom@^18.2.0:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"