баг при смене страницы

This commit is contained in:
2025-07-24 15:39:44 +03:00
parent a13bbc7a68
commit f9e7905d55
16 changed files with 2356 additions and 9 deletions

View File

@@ -0,0 +1,227 @@
import { Badge } from '@/component/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
import { Label } from '@/component/ui/label'
import {
Select,
SelectContent,
SelectItem,
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'
interface VerificationConditionsEditorProps {
config: VerificationConditionsConfig
onChange: (config: VerificationConditionsConfig) => void
}
export function VerificationConditionsEditor({
config,
onChange,
}: VerificationConditionsEditorProps) {
const { data: laboratories = [], isLoading } = useLaboratories()
const updateConfig = (updates: Partial<VerificationConditionsConfig>) => {
onChange({ ...config, ...updates })
}
// Получаем выбранную лабораторию
const selectedLab = laboratories.find(
lab => lab.id === config.selectedLaboratoryId
)
// Получаем все доступные условия (либо все из БД, либо условия конкретной лаборатории)
const availableConditions = config.selectedLaboratoryId
? mockApiHelpers.getLaboratoryConditions(config.selectedLaboratoryId)
: mockApiHelpers.getAllConditions()
// Обновление привязки условия к ячейке
const updateConditionMapping = (cellIndex: number, conditionKey: string) => {
const conditionName =
availableConditions.find(c => c.key === conditionKey)?.name ||
conditionKey
const newMappings = [...(config.conditionMappings || [])]
const existingIndex = newMappings.findIndex(m => m.cellIndex === cellIndex)
if (conditionKey === '__none__') {
// Убираем привязку
if (existingIndex >= 0) {
newMappings.splice(existingIndex, 1)
}
} else {
// Добавляем или обновляем привязку
const mapping: ConditionCellMapping = {
cellIndex,
conditionKey,
conditionName,
}
if (existingIndex >= 0) {
newMappings[existingIndex] = mapping
} else {
newMappings.push(mapping)
}
}
updateConfig({ conditionMappings: newMappings })
}
// Получить выбранное условие для ячейки
const getSelectedCondition = (cellIndex: number): string => {
const mapping = config.conditionMappings?.find(
m => m.cellIndex === cellIndex
)
return mapping?.conditionKey || '__none__'
}
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>
{/* Привязка условий к ячейкам */}
{config.targetCells.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-4 w-4" />
Привязка условий к ячейкам
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-800 dark:bg-blue-950 dark:text-blue-200">
<p className="font-medium">Настройка записи данных</p>
<p className="mt-1">
Выберите какое условие будет записываться в каждую целевую
ячейку.
</p>
</div>
<div className="space-y-3">
{config.targetCells.map((cell, index) => (
<div
key={index}
className="flex items-center gap-3 rounded-md border p-3"
>
<Badge variant="outline" className="shrink-0">
{cell.sheet}!{cell.cell}
</Badge>
<div className="flex-1">
<Label className="text-xs text-muted-foreground">
Ячейка {index + 1} - выберите условие:
</Label>
<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>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</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>
)}
{/* Подсказка если нет целевых ячеек */}
{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>
)}
</div>
)
}

View File

@@ -0,0 +1,131 @@
import { Badge } from '@/component/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
import { Building2, Calendar, Droplets, Gauge, Thermometer } from 'lucide-react'
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" />,
},
],
}
export function VerificationConditionsPreview({
config,
}: VerificationConditionsPreviewProps) {
const displayedConditions = mockData.conditions.slice(0, config.maxConditions)
return (
<Card className="w-full max-w-md">
<CardHeader className="pb-3">
<CardTitle className="text-sm">Условия поверки</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"
>
<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>
</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>
<div>
Ячейка: <span className="font-mono">{config.startCell}</span>
</div>
<div>Макс. условий: {config.maxConditions}</div>
{config.autoSave && <div> Автосохранение</div>}
{config.showUnits && <div> Показывать единицы</div>}
{config.targetCells.length > 0 && (
<div>Ячеек настроено: {config.targetCells.length}</div>
)}
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,311 @@
import { Alert, AlertDescription } from '@/component/ui/alert'
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 {
AlertCircle,
Building2,
Calendar,
CheckCircle2,
Droplets,
Gauge,
Loader2,
Save,
Thermometer,
Zap,
} from 'lucide-react'
import { useEffect } from 'react'
import { useVerificationConditionsElement } from './hooks'
import {
VerificationConditionsConfig,
VerificationConditionsValue,
} from './types'
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" />
}
export function VerificationConditionsRender({
config,
value,
onChange,
}: VerificationConditionsRenderProps) {
const {
laboratories,
selectedLaboratory,
selectedLaboratoryId,
selectedDate,
conditions,
localConditions,
isLoading,
isError,
isSaving,
hasUnsavedChanges,
validationErrors,
changeLaboratory,
changeDate,
updateCondition,
saveConditions,
getCurrentValue,
formatValueWithUnit,
getConditionKey,
} = useVerificationConditionsElement({
selectedLaboratoryId: config.selectedLaboratoryId || value?.laboratoryId,
selectedDate: config.selectedDate || value?.date,
autoSave: config.autoSave,
})
// Синхронизируем значение с родительским компонентом
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)
}
}
}, [onChange, getCurrentValue, value])
// Обработка сохранения
const handleSave = async () => {
try {
await saveConditions()
} catch (error) {
console.error('Ошибка сохранения:', error)
}
}
// Получение ошибки валидации для поля
const getFieldError = (fieldKey: string) => {
return validationErrors.find(error => error.field === fieldKey)
}
if (isLoading) {
return (
<Card>
<CardContent className="flex items-center justify-center p-6">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Загрузка...
</CardContent>
</Card>
)
}
if (isError) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Ошибка загрузки данных условий поверки
</AlertDescription>
</Alert>
)
}
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>
)}
</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>
</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)
return (
<div
key={condition.typeId}
className="flex items-center gap-3 rounded-md border bg-background p-3"
>
<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>
)}
</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>
</div>
)
})}
{conditions.conditions.length > config.maxConditions && (
<div className="text-center text-xs text-muted-foreground">
... и ещё{' '}
{conditions.conditions.length - config.maxConditions} условий
</div>
)}
</div>
</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}
</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>}
</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

@@ -0,0 +1,211 @@
import {
ConditionType,
DailyConditions,
ElementConditionsData,
Laboratory,
SaveConditionsResponse,
VerificationConditionsValue,
} from './types'
const BASE_URL = '/api/v1/verification-conditions'
// API для лабораторий
export const laboratoriesApi = {
// Получить все лаборатории
getAll: async (): Promise<Laboratory[]> => {
const response = await fetch(`${BASE_URL}/laboratories`)
if (!response.ok) {
throw new Error('Ошибка загрузки лабораторий')
}
return response.json()
},
// Получить лабораторию по ID
getById: async (id: number): Promise<Laboratory> => {
const response = await fetch(`${BASE_URL}/laboratories/${id}`)
if (!response.ok) {
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()
},
}
// API для ежедневных условий
export const dailyConditionsApi = {
// Получить условия по фильтрам
get: async (params: {
laboratoryId?: number
date?: string
dateFrom?: string
dateTo?: string
}): Promise<DailyConditions[]> => {
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value) searchParams.append(key, value.toString())
})
const response = await fetch(`${BASE_URL}/daily-conditions?${searchParams}`)
if (!response.ok) {
throw new Error('Ошибка загрузки условий')
}
return response.json()
},
// Получить условия для элемента
getForElement: async (
laboratoryId: number,
date: string
): Promise<ElementConditionsData> => {
const response = await fetch(
`${BASE_URL}/daily-conditions/for-element?laboratoryId=${laboratoryId}&date=${date}`
)
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.ok) {
const error = await response.json()
throw new Error(error.message || 'Ошибка сохранения условий')
}
return response.json()
},
// Создать/обновить условия
save: async (
data: Omit<DailyConditions, 'id' | 'createdAt' | 'updatedAt'>
): Promise<DailyConditions> => {
const response = await fetch(`${BASE_URL}/daily-conditions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Ошибка сохранения условий')
}
return response.json()
},
// Обновить условия
update: async (
id: number,
data: Partial<DailyConditions>
): Promise<DailyConditions> => {
const response = await fetch(`${BASE_URL}/daily-conditions/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error('Ошибка обновления условий')
}
return response.json()
},
// Удалить условия
delete: async (id: number): Promise<void> => {
const response = await fetch(`${BASE_URL}/daily-conditions/${id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Ошибка удаления условий')
}
},
}
// Вспомогательные функции
export const verificationConditionsUtils = {
// Валидация значения условия
validateConditionValue: (
value: any,
dataType: string,
minValue?: number,
maxValue?: number
): { isValid: boolean; error?: string } => {
if (dataType === 'number') {
const numValue = parseFloat(value)
if (isNaN(numValue)) {
return { isValid: false, error: 'Должно быть числом' }
}
if (minValue !== undefined && numValue < minValue) {
return { isValid: false, error: `Минимальное значение: ${minValue}` }
}
if (maxValue !== undefined && numValue > maxValue) {
return { isValid: false, error: `Максимальное значение: ${maxValue}` }
}
}
return { isValid: true }
},
// Форматирование значения с единицей измерения
formatValueWithUnit: (value: any, unit?: string): string => {
if (value === undefined || value === null || value === '') {
return '-'
}
return unit ? `${value} ${unit}` : value.toString()
},
// Получение ключа условия для хранения в conditions JSON
getConditionKey: (conditionTypeName: string): string => {
return conditionTypeName
.toLowerCase()
.replace(/\s+/g, '_')
.replace(/[^\w]/g, '')
},
}

View File

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

View File

@@ -0,0 +1,431 @@
import { useCallback, useEffect, useState } from 'react'
import { mockApiHelpers, mockLaboratories } from './mockData'
import {
ElementConditionsData,
Laboratory,
ValidationError,
VerificationConditionsValue,
} from './types'
// Простой хук для загрузки лабораторий (используем моковые данные)
export function useLaboratories() {
const [data, setData] = useState<Laboratory[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isError, setIsError] = useState(false)
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true)
await mockApiHelpers.delay(300) // Симуляция загрузки
setData(mockLaboratories)
setIsError(false)
} catch (error) {
setIsError(true)
console.error('Ошибка загрузки лабораторий:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [])
return { data, isLoading, isError }
}
// Хук для загрузки конкретной лаборатории
export function useLaboratory(id: number | undefined) {
const [data, setData] = useState<Laboratory | undefined>()
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
useEffect(() => {
if (!id) {
setData(undefined)
return
}
const loadData = async () => {
try {
setIsLoading(true)
await mockApiHelpers.delay(200)
const laboratory = mockApiHelpers.getLaboratoryById(id)
setData(laboratory)
setIsError(!laboratory)
} catch (error) {
setIsError(true)
console.error('Ошибка загрузки лаборатории:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [id])
return { data, isLoading, isError }
}
// Хук для загрузки условий для элемента
export function useElementConditions(
laboratoryId: number | undefined,
date: string | undefined
) {
const [data, setData] = useState<ElementConditionsData | undefined>()
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
useEffect(() => {
if (!laboratoryId || !date) {
setData(undefined)
return
}
const loadData = async () => {
try {
setIsLoading(true)
await mockApiHelpers.delay(400)
const conditions = mockApiHelpers.getElementConditions(
laboratoryId,
date
)
setData(conditions)
setIsError(!conditions)
} catch (error) {
setIsError(true)
console.error('Ошибка загрузки условий:', 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: 'Должно быть числом' }
}
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,
}
}

View File

@@ -0,0 +1,8 @@
export { verificationConditionsDefinition } from './definition'
export { VerificationConditionsEditor } from './VerificationConditionsEditor'
export { VerificationConditionsPreview } from './VerificationConditionsPreview'
export { VerificationConditionsRender } from './VerificationConditionsRender'
export * from './api'
export * from './hooks'
export * from './types'

View File

@@ -0,0 +1,280 @@
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)),
}

View File

@@ -0,0 +1,109 @@
// Типы для элемента "Условия поверки"
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 VerificationConditionsConfig {
selectedLaboratoryId?: number // Выбранная лаборатория
targetCells: Array<{
sheet: string
cell: string
}> // Целевые ячейки из основного редактора элементов
conditionMappings: ConditionCellMapping[] // Привязка условий к ячейкам
}
// Значение элемента
export interface VerificationConditionsValue {
laboratoryId: number
date: string
conditions: Record<string, any> // Значения условий {temperature: 23.5, humidity: 45.2}
}
// Ответы API
export interface ApiResponse<T> {
success: boolean
data?: T
message?: string
error?: string
}
export interface SaveConditionsResponse {
id: number
success: boolean
message: string
}
// Ошибки валидации
export interface ValidationError {
field: string
message: string
value?: any
min?: number
max?: number
}

View File

@@ -0,0 +1,62 @@
import { cn } from '@/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
warning:
'border-amber-500/50 text-amber-700 bg-amber-50 dark:border-amber-500 dark:text-amber-400 dark:bg-amber-950 [&>svg]:text-amber-600 dark:[&>svg]:text-amber-400',
success:
'border-green-500/50 text-green-700 bg-green-50 dark:border-green-500 dark:text-green-400 dark:bg-green-950 [&>svg]:text-green-600 dark:[&>svg]:text-green-400',
},
},
defaultVariants: {
variant: 'default',
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = 'Alert'
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
))
AlertTitle.displayName = 'AlertTitle'
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
))
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertDescription, AlertTitle }