Эталоны

This commit is contained in:
2025-07-24 09:10:08 +03:00
parent 32e5cda438
commit 66b94cdd8f
12 changed files with 1674 additions and 467 deletions

View File

@@ -11,6 +11,7 @@ import {
} from '@/lib/hooks/useToast' } from '@/lib/hooks/useToast'
import { ElementsCreation } from '@/page/ElementsCreation' import { ElementsCreation } from '@/page/ElementsCreation'
import { ProtocolCreation } from '@/page/ProtocolCreation' import { ProtocolCreation } from '@/page/ProtocolCreation'
import { StandardsManagementPage } from '@/page/StandardsManagementPage'
import { TemplateEditPage } from '@/page/TemplateEditPage' import { TemplateEditPage } from '@/page/TemplateEditPage'
import { TemplatesOverviewPage } from '@/page/TemplatesOverviewPage' import { TemplatesOverviewPage } from '@/page/TemplatesOverviewPage'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
@@ -52,6 +53,10 @@ const App: FC = () => {
path="templates/:templateId/protocols" path="templates/:templateId/protocols"
element={<ProtocolCreation />} element={<ProtocolCreation />}
/> />
<Route
path="standards-management"
element={<StandardsManagementPage />}
/>
</Route> </Route>
</Routes> </Routes>
</TemplateProvider> </TemplateProvider>

View File

@@ -1,9 +1,10 @@
import { Badge } from '@/component/ui/badge'
import { Button } from '@/component/ui/button' import { Button } from '@/component/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
import { Input } from '@/component/ui/input'
import { Separator } from '@/component/ui/separator' import { Separator } from '@/component/ui/separator'
import { Calendar, Check, FileText, Settings } from 'lucide-react' import { useToast } from '@/lib/hooks/useToast'
import { useState } from 'react' import { Check, FileText, Gauge, Search, Settings, X } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
interface MeasurementStandard { interface MeasurementStandard {
id: string id: string
@@ -29,7 +30,6 @@ interface StandardsDialogProps {
standards: MeasurementStandard[] standards: MeasurementStandard[]
config: StandardsConfig config: StandardsConfig
onSave: (config: StandardsConfig) => void onSave: (config: StandardsConfig) => void
registryNumber: string
} }
export function StandardsDialog({ export function StandardsDialog({
@@ -38,23 +38,119 @@ export function StandardsDialog({
standards, standards,
config, config,
onSave, onSave,
registryNumber,
}: StandardsDialogProps) { }: StandardsDialogProps) {
const [selectedStandards, setSelectedStandards] = useState<string[]>( const [selectedStandards, setSelectedStandards] = useState<string[]>(
config.selectedStandards 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 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) => { const toggleStandard = (standardId: string) => {
setSelectedStandards(prev => { setSelectedStandards(prev => {
if (prev.includes(standardId)) { if (prev.includes(standardId)) {
return prev.filter(id => id !== standardId) return prev.filter(id => id !== standardId)
} else if (prev.length < 7) { } else if (prev.length < 7) {
return [...prev, standardId] return [...prev, standardId]
} } else {
return prev error('Можно выбрать максимум 7 эталонов', {
title: 'Достигнут лимит',
}) })
return prev
}
})
}
const removeStandard = (standardId: string) => {
setSelectedStandards(prev => prev.filter(id => id !== standardId))
} }
const handleSave = () => { const handleSave = () => {
@@ -66,6 +162,12 @@ export function StandardsDialog({
onClose() onClose()
} }
const handleClose = () => {
setSelectedStandards(config.selectedStandards) // Сбрасываем к исходному значению
setSearchTerm('')
onClose()
}
const isExpiringSoon = (validUntil: string) => { const isExpiringSoon = (validUntil: string) => {
const expiryDate = new Date(validUntil) const expiryDate = new Date(validUntil)
const now = new Date() const now = new Date()
@@ -74,44 +176,36 @@ export function StandardsDialog({
return monthsUntilExpiry <= 3 return monthsUntilExpiry <= 3
} }
return ( const renderStandardCard = (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> standard: MeasurementStandard,
<Card className="max-h-[90vh] w-full max-w-4xl overflow-hidden"> isSelected: boolean,
<CardHeader className="pb-4"> showRemoveButton = false
<div className="flex items-center justify-between"> ) => {
<div> const isDisabled = !isSelected && isMaxReached && !showRemoveButton
<CardTitle className="flex items-center gap-2 text-lg">
<Settings className="h-5 w-5" />
Измерительные эталоны
</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 ( return (
<div <div
key={standard.id} key={standard.id}
onClick={() => toggleStandard(standard.id)} className={`flex items-center gap-3 rounded-md border px-3 py-2 transition-all duration-200 ${
className={`cursor-pointer rounded-lg border p-3 transition-all duration-200 ${
isSelected isSelected
? 'border-primary bg-primary/10 shadow-sm' ? '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' : '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
}
> >
<div className="flex items-start gap-3"> {!showRemoveButton && (
<div <div
className={`mt-1 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors ${ className={`flex h-4 w-4 items-center justify-center rounded border transition-colors ${
isSelected isSelected
? 'border-primary bg-primary' ? 'border-primary bg-primary'
: isDisabled
? 'border-muted-foreground/30 bg-muted'
: 'border-border' : 'border-border'
}`} }`}
> >
@@ -119,64 +213,163 @@ export function StandardsDialog({
<Check className="h-3 w-3 text-primary-foreground" /> <Check className="h-3 w-3 text-primary-foreground" />
)} )}
</div> </div>
)}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2"> <div className="flex items-center gap-3">
<div className="flex-1"> <div
<div className="text-sm font-medium leading-tight text-foreground"> className={`min-w-20 shrink-0 text-sm font-medium ${
isDisabled ? 'text-muted-foreground' : 'text-foreground'
}`}
>
{standard.shortName} {standard.shortName}
</div> </div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground"> <div
className={`truncate text-sm ${
isDisabled
? 'text-muted-foreground/70'
: 'text-muted-foreground'
}`}
>
{standard.name} {standard.name}
</div> </div>
</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>
<div className="mt-2 space-y-1"> <div
<div className="flex items-center gap-1 text-xs text-muted-foreground"> 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" /> <FileText className="h-3 w-3" />
{standard.registryNumber} <span className="hidden sm:inline">{standard.registryNumber}</span>
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="flex items-center gap-1">
Диапазон: {standard.range} <Gauge className="h-3 w-3" />
<span>{standard.range}</span>
</div> </div>
<div className="text-xs text-muted-foreground"> </div>
Действует до:{' '}
{new Date(standard.validUntil).toLocaleDateString( {showRemoveButton && (
'ru-RU' <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> </div>
</div>
</div>
</div>
</div>
) )
})} }
</div>
<Separator />
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<Card className="flex max-h-[95vh] w-full max-w-6xl flex-col overflow-hidden">
<CardHeader className="shrink-0 pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground"> <div>
Максимум 7 эталонов. Выбрано: {selectedStandards.length} <CardTitle className="flex items-center gap-2 text-lg">
<Settings className="h-5 w-5" />
Измерительные эталоны
<span
className={`text-sm font-normal ${
isMaxReached ? 'text-orange-600' : 'text-muted-foreground'
}`}
>
({selectedStandards.length}/7)
</span>
</CardTitle>
</div> </div>
<div className="flex gap-2"> </div>
<Button variant="outline" onClick={onClose}> </CardHeader>
<CardContent className="flex flex-1 flex-col space-y-4 overflow-hidden">
{/* Поиск */}
<div className="relative shrink-0">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder="Начните печатать для поиска..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="pl-10"
autoComplete="off"
autoFocus
/>
{searchTerm && (
<Button
variant="ghost"
size="sm"
className="absolute right-2 top-1/2 h-6 w-6 -translate-y-1/2 p-0"
onClick={() => {
setSearchTerm('')
searchInputRef.current?.focus()
}}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
{/* Контент с прокруткой */}
<div className="flex-1 space-y-4 overflow-y-auto">
{/* Выбранные эталоны */}
{selectedStandardsData.length > 0 && (
<div>
<h4 className="mb-3 text-sm font-medium text-foreground">
Выбранные эталоны ({selectedStandardsData.length})
</h4>
<div className="max-h-64 space-y-1 overflow-y-auto">
{selectedStandardsData.map(standard =>
renderStandardCard(standard, true, true)
)}
</div>
</div>
)}
{selectedStandardsData.length > 0 &&
unselectedStandards.length > 0 && <Separator />}
{/* Доступные эталоны */}
{unselectedStandards.length > 0 && (
<div>
<h4 className="mb-3 text-sm font-medium text-foreground">
Доступные эталоны ({unselectedStandards.length})
{isMaxReached && (
<span className="ml-2 text-xs text-orange-600">
Достигнут лимит выбора
</span>
)}
</h4>
<div className="space-y-1">
{unselectedStandards.map(standard =>
renderStandardCard(standard, false)
)}
</div>
</div>
)}
{filteredStandards.length === 0 && searchTerm && (
<div className="py-8 text-center text-muted-foreground">
Эталоны не найдены по запросу "{searchTerm}"
</div>
)}
</div>
<Separator className="shrink-0" />
{/* Зафиксированные кнопки */}
<div className="shrink-0">
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleClose} size="sm">
Отмена Отмена
</Button> </Button>
<Button onClick={handleSave}>Сохранить</Button> <Button onClick={handleSave} size="sm">
Сохранить
</Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@@ -1,3 +1,4 @@
import { Button } from '@/component/ui/button'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -7,12 +8,15 @@ import {
} from '@/component/ui/select' } from '@/component/ui/select'
import { CellTarget } from '@/type/template' import { CellTarget } from '@/type/template'
import { Info, Settings } from 'lucide-react' import { Info, Settings } from 'lucide-react'
import { useState } from 'react'
import { StandardsSelector } from './StandardsSelector'
interface StandardsConfig { interface StandardsConfig {
sheet: string // Лист Excel sheet: string // Лист Excel
startCell: string // Первая ячейка (например "A10") startCell: string // Первая ячейка (например "A10")
maxItems: number // Обычно 7 maxItems: number // Обычно 7
targetCells: CellTarget[] targetCells: CellTarget[]
selectedStandards?: string[] // Выбранные эталоны (ID)
} }
interface StandardsEditorProps { interface StandardsEditorProps {
@@ -24,12 +28,15 @@ export const StandardsEditor: React.FC<StandardsEditorProps> = ({
config, config,
onChange, onChange,
}) => { }) => {
const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false)
// Обеспечиваем значения по умолчанию // Обеспечиваем значения по умолчанию
const safeConfig = { const safeConfig = {
sheet: config.sheet || 'L', sheet: config.sheet || 'L',
startCell: config.startCell || 'A1', startCell: config.startCell || 'A1',
maxItems: config.maxItems || 7, maxItems: config.maxItems || 7,
targetCells: config.targetCells || [], targetCells: config.targetCells || [],
selectedStandards: config.selectedStandards || [],
} }
const updateConfig = (updates: Partial<StandardsConfig>) => { const updateConfig = (updates: Partial<StandardsConfig>) => {
@@ -69,16 +76,57 @@ export const StandardsEditor: React.FC<StandardsEditorProps> = ({
</p> </p>
</div> </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"> <div className="flex items-start gap-2">
<Info className="mt-0.5 h-4 w-4 text-blue-600" /> <Info className="mt-0.5 h-4 w-4 text-blue-600" />
<div className="text-sm"> <div className="text-sm">
<div className="font-medium text-blue-800">Настройка ячеек</div> <p className="font-medium text-blue-900">Как это работает:</p>
<div className="mt-1 text-xs text-blue-600"> <ul className="mt-1 list-disc space-y-1 pl-4 text-blue-800">
Ячейки для размещения эталонов настраиваются в разделе "Целевые <li>Выберите максимальное количество эталонов</li>
ячейки" выше. Эталоны будут записываться в указанные ячейки по <li>Настройте предустановленные эталоны (опционально)</li>
порядку. <li>При создании протокола пользователь сможет изменить выбор</li>
</div> </ul>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,8 @@
import { Badge } from '@/component/ui/badge' import { Badge } from '@/component/ui/badge'
import { Button } from '@/component/ui/button' import { Button } from '@/component/ui/button'
import { Label } from '@/component/ui/label' import { Label } from '@/component/ui/label'
import { Settings, Wrench } from 'lucide-react' import { FileText, Settings } from 'lucide-react'
import { mockStandards } from './mockStandards'
interface StandardsConfig { interface StandardsConfig {
sheet: string sheet: string
@@ -14,77 +15,6 @@ interface StandardsPreviewProps {
config: StandardsConfig 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> = ({ export const StandardsPreview: React.FC<StandardsPreviewProps> = ({
config, config,
}) => { }) => {
@@ -104,28 +34,33 @@ export const StandardsPreview: React.FC<StandardsPreviewProps> = ({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="space-y-1.5"> <div className="space-y-1">
{previewStandards.map((standard, index) => ( {previewStandards.map((standard, index) => (
<div <div
key={standard.id} key={standard.id}
className="flex items-center gap-2 rounded-md bg-muted/30 p-2 text-sm" 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"> <span className="text-xs font-medium text-primary">
{index + 1} {index + 1}
</span> </span>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-foreground"> <div className="truncate text-xs font-medium text-foreground">
{standard.shortName} {standard.name}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
{standard.accuracy} {standard.range} <FileText className="h-2.5 w-2.5" />
<span className="hidden text-xs sm:inline">
{standard.registryNumber}
</span>
</div> </div>
</div> </div>
<Badge variant="secondary" className="shrink-0 text-xs"> <Badge
<Wrench className="mr-1 h-3 w-3" /> variant="secondary"
{getStandardTypeBadge(standard.type)} className="shrink-0 px-1.5 py-0.5 text-xs"
>
{standard.range}
</Badge> </Badge>
</div> </div>
))} ))}

View File

@@ -6,106 +6,146 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/component/ui/dialog' } from '@/component/ui/dialog'
import { Calendar, Check, FileText, Settings, X } from 'lucide-react' import { Input } from '@/component/ui/input'
import { useState } from 'react' import { Separator } from '@/component/ui/separator'
import { useToast } from '@/lib/hooks/useToast'
interface MeasurementStandard { import {
id: string Check,
name: string ExternalLink,
shortName: string FileText,
type: string Search,
registryNumber: string Settings,
range: string X,
accuracy: string } from 'lucide-react'
certificateNumber: string import { useEffect, useMemo, useRef, useState } from 'react'
validUntil: string import { useStandards } from './hooks'
} import { Standard } from './types'
interface StandardsSelectorProps { interface StandardsSelectorProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
value: string[] value: string[]
onChange: (value: string[]) => void 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({ export function StandardsSelector({
isOpen, isOpen,
onClose, onClose,
value = [], value = [],
onChange, onChange,
registryNumber = 'ГРСИ 12345', maxItems = 7, // Значение по умолчанию
}: StandardsSelectorProps) { }: StandardsSelectorProps) {
const [selectedStandards, setSelectedStandards] = useState<string[]>(value) 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) => { const toggleStandard = (standardId: string) => {
setSelectedStandards(prev => { setSelectedStandards(prev => {
if (prev.includes(standardId)) { if (prev.includes(standardId)) {
return prev.filter(id => id !== standardId) return prev.filter(id => id !== standardId)
} else if (prev.length < 7) { } else if (prev.length < maxItems) {
return [...prev, standardId] return [...prev, standardId]
} } else {
return prev error(`Можно выбрать максимум ${maxItems} эталонов`, {
title: 'Достигнут лимит',
}) })
return prev
}
})
}
const removeStandard = (standardId: string) => {
setSelectedStandards(prev => prev.filter(id => id !== standardId))
} }
const handleSave = () => { const handleSave = () => {
@@ -114,59 +154,41 @@ export function StandardsSelector({
} }
const handleClose = () => { const handleClose = () => {
setSelectedStandards(value) // Сбрасываем к исходному значению setSelectedStandards(value)
setSearchTerm('')
onClose() onClose()
} }
const isExpiringSoon = (validUntil: string) => { const renderStandardCard = (
const expiryDate = new Date(validUntil) standard: Standard,
const now = new Date() isSelected: boolean,
const monthsUntilExpiry = showRemoveButton = false
(expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30) ) => {
return monthsUntilExpiry <= 3 const isDisabled = !isSelected && isMaxReached && !showRemoveButton
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden">
<DialogHeader>
<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>
</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 ( return (
<div <div
key={standard.id} key={standard.id}
onClick={() => toggleStandard(standard.id)} className={`flex items-center gap-3 rounded-md border px-3 py-2 transition-all duration-200 ${
className={`cursor-pointer rounded-lg border p-3 transition-all duration-200 ${
isSelected isSelected
? 'border-primary bg-primary/10 shadow-sm' ? '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' : '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
}
> >
<div className="flex items-start gap-3"> {!showRemoveButton && (
<div <div
className={`mt-1 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors ${ className={`flex h-4 w-4 items-center justify-center rounded border transition-colors ${
isSelected isSelected
? 'border-primary bg-primary' ? 'border-primary bg-primary'
: isDisabled
? 'border-muted-foreground/30 bg-muted'
: 'border-border' : 'border-border'
}`} }`}
> >
@@ -174,63 +196,211 @@ export function StandardsSelector({
<Check className="h-3 w-3 text-primary-foreground" /> <Check className="h-3 w-3 text-primary-foreground" />
)} )}
</div> </div>
)}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2"> <div className="flex items-center gap-3">
<div className="flex-1"> <div
<div className="text-sm font-medium leading-tight text-foreground"> className={`min-w-20 shrink-0 text-sm font-medium ${
{standard.shortName} isDisabled ? 'text-muted-foreground' : 'text-foreground'
}`}
>
{standard.protocol_name}
</div> </div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground"> <div
className={`truncate text-sm ${
isDisabled
? 'text-muted-foreground/70'
: 'text-muted-foreground'
}`}
>
{standard.name} {standard.name}
</div> </div>
</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>
<div className="mt-2 space-y-1"> <div
<div className="flex items-center gap-1 text-xs text-muted-foreground"> 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" /> <FileText className="h-3 w-3" />
{standard.registryNumber} <span className="hidden sm:inline">{standard.registry_number}</span>
</div> </div>
<div className="text-xs text-muted-foreground"> <Badge variant="secondary" className="px-1.5 py-0.5 text-xs">
Диапазон: {standard.range} {standard.range}
</Badge>
</div> </div>
<div className="text-xs text-muted-foreground">
Действует до:{' '} {showRemoveButton && (
{new Date(standard.validUntil).toLocaleDateString( <Button
'ru-RU' 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> </div>
</div>
</div>
</div>
</div>
) )
})} }
</div>
<div className="flex items-center justify-between border-t pt-4"> if (isLoading) {
<div className="text-sm text-muted-foreground"> return (
Максимум 7 эталонов. Выбрано: {selectedStandards.length} <Dialog open={isOpen} onOpenChange={handleClose}>
</div> <DialogContent className="flex max-h-[95vh] max-w-6xl flex-col overflow-hidden">
<div className="flex gap-2"> <div className="flex h-64 items-center justify-center">
<Button variant="outline" onClick={handleClose}> <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>
</Button> <p className="text-muted-foreground">Загрузка эталонов...</p>
<Button onClick={handleSave}>Сохранить</Button> </div>
</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="flex max-h-[95vh] max-w-6xl flex-col overflow-hidden">
<DialogHeader className="shrink-0 pb-3">
<div className="flex items-center justify-between">
<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="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()
}}
>
<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-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>
</div> </div>
</DialogContent> </DialogContent>

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

View File

@@ -3,93 +3,20 @@ import { Button } from '@/component/ui/button'
import { Label } from '@/component/ui/label' import { Label } from '@/component/ui/label'
import { ElementDefinition } from '@/entitiy/element/model/interface' import { ElementDefinition } from '@/entitiy/element/model/interface'
import { CellTarget } from '@/type/template' import { CellTarget } from '@/type/template'
import { Settings, Weight, Wrench } from 'lucide-react' import { FileText, Settings, Weight } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useStandards } from './hooks'
import { getStandardById } from './mockStandards'
import { StandardsEditor } from './StandardsEditor' import { StandardsEditor } from './StandardsEditor'
import { StandardsPreview } from './StandardsPreview' import { StandardsPreview } from './StandardsPreview'
import { StandardsSelector } from './StandardsSelector' 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 { interface StandardsConfig {
sheet: string // Лист Excel sheet: string // Лист Excel
startCell: string // Первая ячейка (например "A10") startCell: string // Первая ячейка (например "A10")
maxItems: number // Обычно 7 maxItems: number // Обычно 7
targetCells: CellTarget[] targetCells: CellTarget[]
selectedStandards?: string[] // Выбранные эталоны (ID)
} }
export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> = export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
@@ -103,30 +30,30 @@ export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
startCell: 'A1', startCell: 'A1',
maxItems: 7, maxItems: 7,
targetCells: [], targetCells: [],
selectedStandards: [], // По умолчанию пустой массив
}, },
Editor: StandardsEditor, Editor: StandardsEditor,
Preview: StandardsPreview, Preview: StandardsPreview, // Использует моки для демонстрации
Render: ({ config, value, onChange }) => { Render: ({ config, value, onChange }) => {
const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false) const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false)
const [selectedStandardIds, setSelectedStandardIds] = useState<string[]>( const [selectedStandardIds, setSelectedStandardIds] = useState<string[]>(
[] // Инициализируем из config.selectedStandards или value
config.selectedStandards || (Array.isArray(value) ? value : [])
) )
// Если value содержит названия эталонов, находим соответствующие ID // Получаем реальные данные эталонов
useEffect(() => { const { data: standards = [] } = useStandards()
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])
// Синхронизируем selectedStandardIds с config и value
useEffect(() => {
const standards =
config.selectedStandards || (Array.isArray(value) ? value : [])
setSelectedStandardIds(standards)
}, [config.selectedStandards, value])
// Получаем данные выбранных эталонов из реального API
const selectedStandardsData = selectedStandardIds const selectedStandardsData = selectedStandardIds
.map(id => getStandardById(id)) .map(id => standards.find(s => s.id === id))
.filter(Boolean) .filter(Boolean)
return ( return (
@@ -148,7 +75,7 @@ export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
<div className="space-y-2"> <div className="space-y-2">
{selectedStandardsData.length > 0 ? ( {selectedStandardsData.length > 0 ? (
<div className="space-y-1.5"> <div className="space-y-1">
{selectedStandardsData.map( {selectedStandardsData.map(
(standard, index) => (standard, index) =>
standard && ( standard && (
@@ -156,25 +83,27 @@ export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
key={standard.id} key={standard.id}
className="flex items-center gap-2 rounded-md bg-muted/30 p-2 text-sm" 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"> <span className="text-xs font-medium text-primary">
{index + 1} {index + 1}
</span> </span>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-foreground"> <div className="truncate text-xs font-medium text-foreground">
{standard.shortName} {standard.name}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
{standard.accuracy} {standard.range} <FileText className="h-2.5 w-2.5" />
<span className="hidden text-xs sm:inline">
{standard.registry_number}
</span>
</div> </div>
</div> </div>
<Badge <Badge
variant="secondary" 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" /> {standard.range}
{getStandardTypeBadge(standard.type)}
</Badge> </Badge>
</div> </div>
) )
@@ -195,12 +124,10 @@ export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
value={selectedStandardIds} value={selectedStandardIds}
onChange={standardIds => { onChange={standardIds => {
setSelectedStandardIds(standardIds) setSelectedStandardIds(standardIds)
// Преобразуем ID эталонов в их названия для сохранения в ячейки // Сохраняем ID эталонов в value для записи в Excel
const standardNames = standardIds.map( onChange?.(standardIds)
id => getStandardById(id)?.name || ''
)
onChange?.(standardNames)
}} }}
maxItems={config.maxItems}
/> />
</div> </div>
) )
@@ -213,8 +140,13 @@ export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
// Используем targetCells из конфигурации // Используем targetCells из конфигурации
const cells = cfg.targetCells || [] const cells = cfg.targetCells || []
// Сопоставляем значения с ячейками // Конвертируем ID эталонов в их названия для записи в Excel
const standardNames = Array.isArray(value) ? value : [] // Здесь используем моки для быстрого доступа к данным при записи в Excel
const standardIds = Array.isArray(value) ? value : []
const standardNames = standardIds.map(
id => getStandardById(id)?.name || ''
)
return cells.map((target, idx) => ({ return cells.map((target, idx) => ({
target, target,
value: standardNames[idx] || '', // Берем значение по индексу или пустую строку value: standardNames[idx] || '', // Берем значение по индексу или пустую строку

View 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}`)
},
})
}

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

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

View File

@@ -0,0 +1,485 @@
import {
useCreateStandard,
useDeleteStandard,
useStandards,
useUpdateStandard,
} from '@/component/StandardsElement/hooks'
import {
CreateStandardInput,
PatchStandardInput,
Standard,
} from '@/component/StandardsElement/types'
import { Badge } from '@/component/ui/badge'
import { Button } from '@/component/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/component/ui/dialog'
import { Input } from '@/component/ui/input'
import { Label } from '@/component/ui/label'
import {
Calendar,
Edit,
FileText,
Gauge,
Plus,
Search,
Trash2,
Weight,
} from 'lucide-react'
import { useEffect, useState } from 'react'
export const StandardsManagementPage = () => {
const [searchTerm, setSearchTerm] = useState('')
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [editingStandard, setEditingStandard] = useState<Standard | null>(null)
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const { data: standards = [], isLoading, error } = useStandards()
const createMutation = useCreateStandard()
const updateMutation = useUpdateStandard()
const deleteMutation = useDeleteStandard()
// Фильтрация эталонов
const filteredStandards = standards.filter(
standard =>
standard.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
standard.protocol_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
standard.registry_number.toLowerCase().includes(searchTerm.toLowerCase())
)
const handleCreate = (input: CreateStandardInput) => {
createMutation.mutate(input, {
onSuccess: () => {
setIsCreateDialogOpen(false)
},
})
}
const handleUpdate = (id: string, input: PatchStandardInput) => {
updateMutation.mutate(
{ id, input },
{
onSuccess: () => {
setEditingStandard(null)
},
}
)
}
const handleDelete = (id: string) => {
deleteMutation.mutate(id, {
onSuccess: () => {
setDeleteConfirmId(null)
},
})
}
if (isLoading) {
return (
<div className="flex h-96 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>
)
}
if (error) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<p className="mb-2 text-destructive">Ошибка загрузки эталонов</p>
<p className="text-sm text-muted-foreground">{error.message}</p>
</div>
</div>
)
}
return (
<div className="flex h-screen flex-col">
{/* Заголовок страницы */}
<header className="flex-shrink-0 border-b bg-card px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Weight className="h-5 w-5" />
<h1 className="text-lg font-semibold">Управление эталонами</h1>
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-muted-foreground">
Всего эталонов: {standards.length}
</span>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Создать эталон
</Button>
</div>
</header>
{/* Основное содержимое */}
<main className="flex-1 overflow-auto p-6">
<div className="space-y-6">
{/* Поиск */}
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Поиск эталонов..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* Список эталонов */}
{filteredStandards.length > 0 ? (
<section className="space-y-2">
{filteredStandards.map(standard => (
<StandardCard
key={standard.id}
standard={standard}
onEdit={() => setEditingStandard(standard)}
onDelete={() => setDeleteConfirmId(standard.id)}
/>
))}
</section>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="text-muted-foreground">
<Weight className="mx-auto mb-4 h-12 w-12 opacity-50" />
<p className="text-lg font-medium">
{searchTerm ? 'Эталоны не найдены' : 'Эталоны не созданы'}
</p>
<p className="text-sm">
{searchTerm
? `Попробуйте изменить поисковый запрос "${searchTerm}"`
: 'Создайте свой первый эталон для начала работы'}
</p>
</div>
</div>
)}
</div>
</main>
{/* Диалоги */}
<StandardDialog
isOpen={isCreateDialogOpen}
onClose={() => setIsCreateDialogOpen(false)}
onSave={handleCreate}
isLoading={createMutation.isPending}
title="Создать эталон"
/>
<StandardDialog
isOpen={!!editingStandard}
onClose={() => setEditingStandard(null)}
onSave={input =>
editingStandard && handleUpdate(editingStandard.id, input)
}
isLoading={updateMutation.isPending}
title="Редактировать эталон"
initialData={editingStandard || undefined}
/>
<DeleteConfirmDialog
isOpen={!!deleteConfirmId}
onClose={() => setDeleteConfirmId(null)}
onConfirm={() => deleteConfirmId && handleDelete(deleteConfirmId)}
isLoading={deleteMutation.isPending}
standardName={standards.find(s => s.id === deleteConfirmId)?.name || ''}
/>
</div>
)
}
// Компонент карточки эталона
interface StandardCardProps {
standard: Standard
onEdit: () => void
onDelete: () => void
}
const StandardCard = ({ standard, onEdit, onDelete }: StandardCardProps) => {
const isExpiringSoon = () => {
const expiryDate = new Date(standard.valid_until)
const now = new Date()
const monthsUntilExpiry =
(expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30)
return monthsUntilExpiry <= 3
}
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-3 py-3 transition-all duration-200 hover:border-primary/50 hover:bg-primary/5">
{/* Основная информация */}
<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 text-foreground">
{standard.protocol_name}
</div>
<div className="truncate text-sm text-muted-foreground">
{standard.name}
</div>
{isExpiringSoon() && (
<Badge variant="destructive" className="shrink-0 text-xs">
<Calendar className="mr-1 h-3 w-3" />
Истекает
</Badge>
)}
</div>
</div>
{/* Дополнительная информация */}
<div className="flex shrink-0 items-center gap-4 text-xs 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 className="hidden items-center gap-1 md:flex">
<Gauge className="h-3 w-3" />
<span>{standard.accuracy}</span>
</div>
</div>
{/* Действия */}
<div className="flex shrink-0 gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
onClick={onEdit}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
)
}
// Диалог создания/редактирования эталона
interface StandardDialogProps {
isOpen: boolean
onClose: () => void
onSave: (input: CreateStandardInput) => void
isLoading: boolean
title: string
initialData?: Standard
}
const StandardDialog = ({
isOpen,
onClose,
onSave,
isLoading,
title,
initialData,
}: StandardDialogProps) => {
const [formData, setFormData] = useState<CreateStandardInput>({
name: initialData?.name || '',
protocol_name: initialData?.protocol_name || '',
registry_number: initialData?.registry_number || '',
range: initialData?.range || '',
accuracy: initialData?.accuracy || '',
valid_until: initialData?.valid_until || '',
})
// Обновляем formData при изменении initialData
useEffect(() => {
if (initialData) {
setFormData({
name: initialData.name || '',
protocol_name: initialData.protocol_name || '',
registry_number: initialData.registry_number || '',
range: initialData.range || '',
accuracy: initialData.accuracy || '',
valid_until: initialData.valid_until || '',
})
}
}, [initialData])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSave(formData)
}
const handleClose = () => {
if (!initialData) {
setFormData({
name: '',
protocol_name: '',
registry_number: '',
range: '',
accuracy: '',
valid_until: '',
})
}
onClose()
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="name">Название</Label>
<Input
id="name"
value={formData.name}
onChange={e =>
setFormData(prev => ({ ...prev, name: e.target.value }))
}
placeholder="Эталон массы 1 кг"
required
/>
</div>
<div>
<Label htmlFor="protocol_name">Протокольное название</Label>
<Input
id="protocol_name"
value={formData.protocol_name}
onChange={e =>
setFormData(prev => ({
...prev,
protocol_name: e.target.value,
}))
}
placeholder="ЭМ-1кг"
required
/>
</div>
<div>
<Label htmlFor="registry_number">Номер в реестре</Label>
<Input
id="registry_number"
value={formData.registry_number}
onChange={e =>
setFormData(prev => ({
...prev,
registry_number: e.target.value,
}))
}
placeholder="ГРСИ 12345-01"
required
/>
</div>
<div>
<Label htmlFor="range">Диапазон</Label>
<Input
id="range"
value={formData.range}
onChange={e =>
setFormData(prev => ({ ...prev, range: e.target.value }))
}
placeholder="0.5-2 кг"
required
/>
</div>
<div>
<Label htmlFor="accuracy">Точность</Label>
<Input
id="accuracy"
value={formData.accuracy}
onChange={e =>
setFormData(prev => ({ ...prev, accuracy: e.target.value }))
}
placeholder="±0.001 г"
required
/>
</div>
<div>
<Label htmlFor="valid_until">Действует до</Label>
<Input
id="valid_until"
type="date"
value={formData.valid_until}
onChange={e =>
setFormData(prev => ({ ...prev, valid_until: e.target.value }))
}
required
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={handleClose}>
Отмена
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Сохранение...' : 'Сохранить'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
// Диалог подтверждения удаления
interface DeleteConfirmDialogProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
isLoading: boolean
standardName: string
}
const DeleteConfirmDialog = ({
isOpen,
onClose,
onConfirm,
isLoading,
standardName,
}: DeleteConfirmDialogProps) => {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Удалить эталон?</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-muted-foreground">
Вы действительно хотите удалить эталон{' '}
<strong>{standardName}</strong>?
</p>
<p className="text-sm text-muted-foreground">
Это действие нельзя отменить.
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
Отмена
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? 'Удаление...' : 'Удалить'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -18,7 +18,8 @@ export default defineConfig({
port: 80, // Явно указать порт (по умолчанию 5173) port: 80, // Явно указать порт (по умолчанию 5173)
proxy: { proxy: {
'/api': { '/api': {
target: 'http://192.169.0.163:8000', //192.168.2.66
target: 'http://localhost:8000',
changeOrigin: true, changeOrigin: true,
rewrite: path => path.replace(/^\/api/, ''), rewrite: path => path.replace(/^\/api/, ''),
}, },