Эталоны
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
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 { Separator } from '@/component/ui/separator'
|
||||
import { Calendar, Check, FileText, Settings } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
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
|
||||
@@ -29,7 +30,6 @@ interface StandardsDialogProps {
|
||||
standards: MeasurementStandard[]
|
||||
config: StandardsConfig
|
||||
onSave: (config: StandardsConfig) => void
|
||||
registryNumber: string
|
||||
}
|
||||
|
||||
export function StandardsDialog({
|
||||
@@ -38,25 +38,121 @@ export function StandardsDialog({
|
||||
standards,
|
||||
config,
|
||||
onSave,
|
||||
registryNumber,
|
||||
}: 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
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
|
||||
const removeStandard = (standardId: string) => {
|
||||
setSelectedStandards(prev => prev.filter(id => id !== standardId))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
...config,
|
||||
@@ -66,6 +162,12 @@ export function StandardsDialog({
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedStandards(config.selectedStandards) // Сбрасываем к исходному значению
|
||||
setSearchTerm('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const isExpiringSoon = (validUntil: string) => {
|
||||
const expiryDate = new Date(validUntil)
|
||||
const now = new Date()
|
||||
@@ -74,109 +176,200 @@ export function StandardsDialog({
|
||||
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="max-h-[90vh] w-full max-w-4xl overflow-hidden">
|
||||
<CardHeader className="pb-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>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
ГРСИ: {registryNumber} • Выбрано: {selectedStandards.length}/7
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent grid max-h-96 grid-cols-1 gap-3 overflow-y-auto md:grid-cols-2">
|
||||
{standards.map(standard => {
|
||||
const isSelected = selectedStandards.includes(standard.id)
|
||||
const isExpiring = isExpiringSoon(standard.validUntil)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={standard.id}
|
||||
onClick={() => toggleStandard(standard.id)}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 shadow-sm'
|
||||
: 'border-border hover:border-primary/50 hover:bg-primary/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`mt-1 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<Check className="h-3 w-3 text-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium leading-tight text-foreground">
|
||||
{standard.shortName}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{standard.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{standard.accuracy}
|
||||
</Badge>
|
||||
{isExpiring && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<Calendar className="mr-1 h-3 w-3" />
|
||||
Истекает
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FileText className="h-3 w-3" />
|
||||
{standard.registryNumber}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Диапазон: {standard.range}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Действует до:{' '}
|
||||
{new Date(standard.validUntil).toLocaleDateString(
|
||||
'ru-RU'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<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>
|
||||
|
||||
<Separator />
|
||||
{/* Контент с прокруткой */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Максимум 7 эталонов. Выбрано: {selectedStandards.length}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{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}>Сохранить</Button>
|
||||
<Button onClick={handleSave} size="sm">
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Button } from '@/component/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -7,12 +8,15 @@ import {
|
||||
} 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)
|
||||
}
|
||||
|
||||
interface StandardsEditorProps {
|
||||
@@ -24,12 +28,15 @@ export const StandardsEditor: React.FC<StandardsEditorProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false)
|
||||
|
||||
// Обеспечиваем значения по умолчанию
|
||||
const safeConfig = {
|
||||
sheet: config.sheet || 'L',
|
||||
startCell: config.startCell || 'A1',
|
||||
maxItems: config.maxItems || 7,
|
||||
targetCells: config.targetCells || [],
|
||||
selectedStandards: config.selectedStandards || [],
|
||||
}
|
||||
|
||||
const updateConfig = (updates: Partial<StandardsConfig>) => {
|
||||
@@ -69,16 +76,57 @@ export const StandardsEditor: React.FC<StandardsEditorProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Предустановленные эталоны</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsStandardsDialogOpen(true)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{safeConfig.selectedStandards.length > 0
|
||||
? `Выбрано: ${safeConfig.selectedStandards.length}`
|
||||
: 'Выбрать эталоны'}
|
||||
</Button>
|
||||
{safeConfig.selectedStandards.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => updateConfig({ selectedStandards: [] })}
|
||||
>
|
||||
Очистить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Эталоны, которые будут выбраны по умолчанию при создании протокола
|
||||
</p>
|
||||
|
||||
<StandardsSelector
|
||||
isOpen={isStandardsDialogOpen}
|
||||
onClose={() => setIsStandardsDialogOpen(false)}
|
||||
value={safeConfig.selectedStandards}
|
||||
onChange={standardIds => {
|
||||
updateConfig({ selectedStandards: standardIds })
|
||||
}}
|
||||
maxItems={safeConfig.maxItems}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-blue-50 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="mt-0.5 h-4 w-4 text-blue-600" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-blue-800">Настройка ячеек</div>
|
||||
<div className="mt-1 text-xs text-blue-600">
|
||||
Ячейки для размещения эталонов настраиваются в разделе "Целевые
|
||||
ячейки" выше. Эталоны будут записываться в указанные ячейки по
|
||||
порядку.
|
||||
</div>
|
||||
<p className="font-medium text-blue-900">Как это работает:</p>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-4 text-blue-800">
|
||||
<li>Выберите максимальное количество эталонов</li>
|
||||
<li>Настройте предустановленные эталоны (опционально)</li>
|
||||
<li>При создании протокола пользователь сможет изменить выбор</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Badge } from '@/component/ui/badge'
|
||||
import { Button } from '@/component/ui/button'
|
||||
import { Label } from '@/component/ui/label'
|
||||
import { Settings, Wrench } from 'lucide-react'
|
||||
import { FileText, Settings } from 'lucide-react'
|
||||
import { mockStandards } from './mockStandards'
|
||||
|
||||
interface StandardsConfig {
|
||||
sheet: string
|
||||
@@ -14,77 +15,6 @@ interface StandardsPreviewProps {
|
||||
config: StandardsConfig
|
||||
}
|
||||
|
||||
// Моковые данные для эталонов (такие же как в definition.tsx)
|
||||
const mockStandards = [
|
||||
{
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
// Функция для получения типа эталона в сокращенном виде
|
||||
const getStandardTypeBadge = (type: string) => {
|
||||
switch (type) {
|
||||
case 'Манометр грузопоршневой':
|
||||
return 'МГП'
|
||||
case 'Калибратор давления':
|
||||
return 'КД'
|
||||
default:
|
||||
return 'МО'
|
||||
}
|
||||
}
|
||||
|
||||
export const StandardsPreview: React.FC<StandardsPreviewProps> = ({
|
||||
config,
|
||||
}) => {
|
||||
@@ -104,28 +34,33 @@ export const StandardsPreview: React.FC<StandardsPreviewProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<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-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<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.shortName}
|
||||
{standard.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{standard.accuracy} • {standard.range}
|
||||
<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 text-xs">
|
||||
<Wrench className="mr-1 h-3 w-3" />
|
||||
{getStandardTypeBadge(standard.type)}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{standard.range}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -6,231 +6,401 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/component/ui/dialog'
|
||||
import { Calendar, Check, FileText, Settings, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface MeasurementStandard {
|
||||
id: string
|
||||
name: string
|
||||
shortName: string
|
||||
type: string
|
||||
registryNumber: string
|
||||
range: string
|
||||
accuracy: string
|
||||
certificateNumber: string
|
||||
validUntil: string
|
||||
}
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { Separator } from '@/component/ui/separator'
|
||||
import { useToast } from '@/lib/hooks/useToast'
|
||||
import {
|
||||
Check,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Search,
|
||||
Settings,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useStandards } from './hooks'
|
||||
import { Standard } from './types'
|
||||
|
||||
interface StandardsSelectorProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
registryNumber?: string
|
||||
maxItems?: number // Добавляем опциональный параметр
|
||||
}
|
||||
|
||||
// Моковые данные для эталонов
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
export function StandardsSelector({
|
||||
isOpen,
|
||||
onClose,
|
||||
value = [],
|
||||
onChange,
|
||||
registryNumber = 'ГРСИ 12345',
|
||||
maxItems = 7, // Значение по умолчанию
|
||||
}: StandardsSelectorProps) {
|
||||
const [selectedStandards, setSelectedStandards] = useState<string[]>(value)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const { error } = useToast()
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { data: standards = [], isLoading, error: apiError } = useStandards()
|
||||
|
||||
// Автофокус на поле поиска при открытии
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
searchInputRef.current?.focus()
|
||||
const closeButton = document.querySelector(
|
||||
'[data-state="open"] button[aria-label="Close"]'
|
||||
)
|
||||
if (closeButton && document.activeElement === closeButton) {
|
||||
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
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
const filteredStandards = useMemo(() => {
|
||||
if (!searchTerm) return standards
|
||||
|
||||
const searchLower = searchTerm.toLowerCase()
|
||||
return standards.filter(
|
||||
standard =>
|
||||
standard.name.toLowerCase().includes(searchLower) ||
|
||||
standard.protocol_name.toLowerCase().includes(searchLower) ||
|
||||
standard.registry_number.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 >= maxItems
|
||||
|
||||
const toggleStandard = (standardId: string) => {
|
||||
setSelectedStandards(prev => {
|
||||
if (prev.includes(standardId)) {
|
||||
return prev.filter(id => id !== standardId)
|
||||
} else if (prev.length < 7) {
|
||||
} else if (prev.length < maxItems) {
|
||||
return [...prev, standardId]
|
||||
} else {
|
||||
error(`Можно выбрать максимум ${maxItems} эталонов`, {
|
||||
title: 'Достигнут лимит',
|
||||
})
|
||||
return prev
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
|
||||
const removeStandard = (standardId: string) => {
|
||||
setSelectedStandards(prev => prev.filter(id => id !== standardId))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onChange(selectedStandards)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedStandards(value) // Сбрасываем к исходному значению
|
||||
setSelectedStandards(value)
|
||||
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: Standard,
|
||||
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.protocol_name}
|
||||
</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.registry_number}</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="px-1.5 py-0.5 text-xs">
|
||||
{standard.range}
|
||||
</Badge>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="flex max-h-[95vh] max-w-6xl flex-col overflow-hidden">
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||
<p className="text-muted-foreground">Загрузка эталонов...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (apiError) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="flex max-h-[95vh] max-w-6xl flex-col overflow-hidden">
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="mb-2 text-destructive">Ошибка загрузки эталонов</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{apiError.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[95vh] max-w-6xl flex-col overflow-hidden">
|
||||
<DialogHeader className="shrink-0 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||
<Settings className="h-5 w-5" />
|
||||
Измерительные эталоны
|
||||
</DialogTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
ГРСИ: {registryNumber} • Выбрано: {selectedStandards.length}/7
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<DialogTitle 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}/{maxItems})
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid max-h-96 grid-cols-1 gap-3 overflow-y-auto md:grid-cols-2">
|
||||
{mockStandards.map(standard => {
|
||||
const isSelected = selectedStandards.includes(standard.id)
|
||||
const isExpiring = isExpiringSoon(standard.validUntil)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={standard.id}
|
||||
onClick={() => toggleStandard(standard.id)}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 shadow-sm'
|
||||
: 'border-border hover:border-primary/50 hover:bg-primary/5'
|
||||
}`}
|
||||
<div className="flex flex-1 flex-col space-y-4 overflow-hidden">
|
||||
{/* Поиск и ссылка на управление */}
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<div className="relative flex-1">
|
||||
<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()
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`mt-1 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<Check className="h-3 w-3 text-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium leading-tight text-foreground">
|
||||
{standard.shortName}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{standard.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{standard.accuracy}
|
||||
</Badge>
|
||||
{isExpiring && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<Calendar className="mr-1 h-3 w-3" />
|
||||
Истекает
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FileText className="h-3 w-3" />
|
||||
{standard.registryNumber}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Диапазон: {standard.range}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Действует до:{' '}
|
||||
{new Date(standard.validUntil).toLocaleDateString(
|
||||
'ru-RU'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// Открыть страницу управления эталонами
|
||||
window.open('/standards-management', '_blank')
|
||||
}}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Управление
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Максимум 7 эталонов. Выбрано: {selectedStandards.length}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Сохранить</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>
|
||||
)}
|
||||
|
||||
{standards.length === 0 && !searchTerm && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<Settings className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
Эталоны не созданы
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Зафиксированные кнопки */}
|
||||
<div className="mt-4 shrink-0 border-t pt-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleClose} size="sm">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleSave} size="sm">
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
79
src/component/StandardsElement/api.ts
Normal file
79
src/component/StandardsElement/api.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
CreateStandardInput,
|
||||
CreateStandardOutput,
|
||||
GetStandardOutput,
|
||||
GetStandardsOutput,
|
||||
PatchStandardInput,
|
||||
PatchStandardOutput,
|
||||
} from './types'
|
||||
|
||||
const BASE_URL = '/api'
|
||||
|
||||
// Получить все эталоны
|
||||
export const getStandards = async (): Promise<GetStandardsOutput> => {
|
||||
console.log('getStandards', BASE_URL)
|
||||
const response = await fetch(`${BASE_URL}/standards/`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch standards')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Получить один эталон
|
||||
export const getStandard = async (
|
||||
standardId: string
|
||||
): Promise<GetStandardOutput> => {
|
||||
const response = await fetch(`${BASE_URL}/standards/${standardId}/`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch standard')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Создать эталон
|
||||
export const createStandard = async (
|
||||
input: CreateStandardInput
|
||||
): Promise<CreateStandardOutput> => {
|
||||
const response = await fetch(`${BASE_URL}/standards/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create standard')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Обновить эталон
|
||||
export const patchStandard = async (
|
||||
standardId: string,
|
||||
input: PatchStandardInput
|
||||
): Promise<PatchStandardOutput> => {
|
||||
const response = await fetch(`${BASE_URL}/standards/${standardId}/`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update standard')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Удалить эталон
|
||||
export const deleteStandard = async (
|
||||
standardId: string
|
||||
): Promise<{ [key: string]: string }> => {
|
||||
const response = await fetch(`${BASE_URL}/standards/${standardId}/`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete standard')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
@@ -3,93 +3,20 @@ 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 { Settings, Weight, Wrench } from 'lucide-react'
|
||||
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'
|
||||
|
||||
// Моковые данные для эталонов (такие же как в StandardsSelector)
|
||||
const mockStandards = [
|
||||
{
|
||||
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
|
||||
const getStandardById = (id: string) => {
|
||||
return mockStandards.find(standard => standard.id === id)
|
||||
}
|
||||
|
||||
// Функция для получения типа эталона в сокращенном виде
|
||||
const getStandardTypeBadge = (type: string) => {
|
||||
switch (type) {
|
||||
case 'Манометр грузопоршневой':
|
||||
return 'МГП'
|
||||
case 'Калибратор давления':
|
||||
return 'КД'
|
||||
default:
|
||||
return 'МО'
|
||||
}
|
||||
}
|
||||
|
||||
interface StandardsConfig {
|
||||
sheet: string // Лист Excel
|
||||
startCell: string // Первая ячейка (например "A10")
|
||||
maxItems: number // Обычно 7
|
||||
targetCells: CellTarget[]
|
||||
selectedStandards?: string[] // Выбранные эталоны (ID)
|
||||
}
|
||||
|
||||
export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
|
||||
@@ -103,30 +30,30 @@ export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
|
||||
startCell: 'A1',
|
||||
maxItems: 7,
|
||||
targetCells: [],
|
||||
selectedStandards: [], // По умолчанию пустой массив
|
||||
},
|
||||
Editor: StandardsEditor,
|
||||
Preview: StandardsPreview,
|
||||
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 : [])
|
||||
)
|
||||
|
||||
// Если value содержит названия эталонов, находим соответствующие ID
|
||||
useEffect(() => {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const ids = value
|
||||
.map(name => {
|
||||
const standard = mockStandards.find(s => s.name === name)
|
||||
return standard?.id || ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
setSelectedStandardIds(ids)
|
||||
}
|
||||
}, [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 => getStandardById(id))
|
||||
.map(id => standards.find(s => s.id === id))
|
||||
.filter(Boolean)
|
||||
|
||||
return (
|
||||
@@ -148,7 +75,7 @@ export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
|
||||
|
||||
<div className="space-y-2">
|
||||
{selectedStandardsData.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1">
|
||||
{selectedStandardsData.map(
|
||||
(standard, index) =>
|
||||
standard && (
|
||||
@@ -156,25 +83,27 @@ export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
|
||||
key={standard.id}
|
||||
className="flex items-center gap-2 rounded-md bg-muted/30 p-2 text-sm"
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<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.shortName}
|
||||
{standard.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{standard.accuracy} • {standard.range}
|
||||
<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 text-xs"
|
||||
className="shrink-0 px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
<Wrench className="mr-1 h-3 w-3" />
|
||||
{getStandardTypeBadge(standard.type)}
|
||||
{standard.range}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
@@ -195,12 +124,10 @@ export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
|
||||
value={selectedStandardIds}
|
||||
onChange={standardIds => {
|
||||
setSelectedStandardIds(standardIds)
|
||||
// Преобразуем ID эталонов в их названия для сохранения в ячейки
|
||||
const standardNames = standardIds.map(
|
||||
id => getStandardById(id)?.name || ''
|
||||
)
|
||||
onChange?.(standardNames)
|
||||
// Сохраняем ID эталонов в value для записи в Excel
|
||||
onChange?.(standardIds)
|
||||
}}
|
||||
maxItems={config.maxItems}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -213,8 +140,13 @@ export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
|
||||
// Используем targetCells из конфигурации
|
||||
const cells = cfg.targetCells || []
|
||||
|
||||
// Сопоставляем значения с ячейками
|
||||
const standardNames = Array.isArray(value) ? value : []
|
||||
// Конвертируем 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] || '', // Берем значение по индексу или пустую строку
|
||||
|
||||
83
src/component/StandardsElement/hooks.ts
Normal file
83
src/component/StandardsElement/hooks.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useToast } from '@/lib/hooks/useToast'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import * as api from './api'
|
||||
import { CreateStandardInput, PatchStandardInput } from './types'
|
||||
|
||||
// Ключи для React Query
|
||||
export const standardsKeys = {
|
||||
all: ['standards'] as const,
|
||||
detail: (id: string) => ['standards', id] as const,
|
||||
}
|
||||
|
||||
// Получить все эталоны
|
||||
export const useStandards = () => {
|
||||
return useQuery({
|
||||
queryKey: standardsKeys.all,
|
||||
queryFn: api.getStandards,
|
||||
select: data => data.standards,
|
||||
})
|
||||
}
|
||||
|
||||
// Получить один эталон
|
||||
export const useStandard = (standardId: string) => {
|
||||
return useQuery({
|
||||
queryKey: standardsKeys.detail(standardId),
|
||||
queryFn: () => api.getStandard(standardId),
|
||||
select: data => data.standard,
|
||||
enabled: !!standardId,
|
||||
})
|
||||
}
|
||||
|
||||
// Создать эталон
|
||||
export const useCreateStandard = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { success, error } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateStandardInput) => api.createStandard(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: standardsKeys.all })
|
||||
success('Эталон успешно создан')
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
error(`Ошибка создания эталона: ${err.message}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Обновить эталон
|
||||
export const useUpdateStandard = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { success, error } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: PatchStandardInput }) =>
|
||||
api.patchStandard(id, input),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: standardsKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: standardsKeys.detail(id) })
|
||||
success('Эталон успешно обновлен')
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
error(`Ошибка обновления эталона: ${err.message}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Удалить эталон
|
||||
export const useDeleteStandard = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { success, error } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (standardId: string) => api.deleteStandard(standardId),
|
||||
onSuccess: (_, standardId) => {
|
||||
queryClient.invalidateQueries({ queryKey: standardsKeys.all })
|
||||
queryClient.removeQueries({ queryKey: standardsKeys.detail(standardId) })
|
||||
success('Эталон успешно удален')
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
error(`Ошибка удаления эталона: ${err.message}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
229
src/component/StandardsElement/mockStandards.ts
Normal file
229
src/component/StandardsElement/mockStandards.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
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 'ЭТЛ'
|
||||
}
|
||||
}
|
||||
47
src/component/StandardsElement/types.ts
Normal file
47
src/component/StandardsElement/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Типы на основе API схемы
|
||||
export interface Standard {
|
||||
id: string
|
||||
name: string
|
||||
protocol_name: string // было shortName
|
||||
registry_number: string
|
||||
range: string
|
||||
accuracy: string
|
||||
valid_until: string // date format
|
||||
created_at: string // datetime
|
||||
updated_at: string // datetime
|
||||
deleted_at: string | null // datetime or null
|
||||
}
|
||||
|
||||
export interface CreateStandardInput {
|
||||
name: string
|
||||
protocol_name: string
|
||||
registry_number: string
|
||||
range: string
|
||||
accuracy: string
|
||||
valid_until: string // date format
|
||||
}
|
||||
|
||||
export interface CreateStandardOutput {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface PatchStandardInput {
|
||||
name?: string | null
|
||||
protocol_name?: string | null
|
||||
registry_number?: string | null
|
||||
range?: string | null
|
||||
accuracy?: string | null
|
||||
valid_until?: string | null
|
||||
}
|
||||
|
||||
export interface PatchStandardOutput {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface GetStandardOutput {
|
||||
standard: Standard | null
|
||||
}
|
||||
|
||||
export interface GetStandardsOutput {
|
||||
standards: Standard[]
|
||||
}
|
||||
Reference in New Issue
Block a user