Эталоны
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
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="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>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t pt-4">
|
{/* Контент с прокруткой */}
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="flex-1 space-y-4 overflow-y-auto">
|
||||||
Максимум 7 эталонов. Выбрано: {selectedStandards.length}
|
{/* Выбранные эталоны */}
|
||||||
|
{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 gap-2">
|
</div>
|
||||||
<Button variant="outline" onClick={handleClose}>
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
<Button onClick={handleSave}>Сохранить</Button>
|
<Button onClick={handleSave} size="sm">
|
||||||
</div>
|
Сохранить
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</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 { 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] || '', // Берем значение по индексу или пустую строку
|
||||||
|
|||||||
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[]
|
||||||
|
}
|
||||||
485
src/page/StandardsManagementPage.tsx
Normal file
485
src/page/StandardsManagementPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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/, ''),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user