почти рабочий вариант
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import { Button } from '@/component/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/component/ui/select'
|
||||
import { Info, Settings } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { StandardsSelector } from './StandardsSelector'
|
||||
import { StandardsConfig } from './definition'
|
||||
|
||||
interface StandardsEditorProps {
|
||||
config: StandardsConfig
|
||||
onChange: (config: StandardsConfig) => void
|
||||
}
|
||||
|
||||
export const StandardsEditor: React.FC<StandardsEditorProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false)
|
||||
|
||||
// Обеспечиваем значения по умолчанию
|
||||
const safeConfig = {
|
||||
maxItems: config.maxItems || 7,
|
||||
targetCells: config.targetCells || [],
|
||||
selectedStandards: config.selectedStandards || [],
|
||||
}
|
||||
|
||||
const updateConfig = (updates: Partial<StandardsConfig>) => {
|
||||
onChange({ ...safeConfig, ...updates })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium">Максимум эталонов</label>
|
||||
<Select
|
||||
value={safeConfig.maxItems.toString()}
|
||||
onValueChange={value => updateConfig({ maxItems: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-16">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">3</SelectItem>
|
||||
<SelectItem value="7">7</SelectItem>
|
||||
<SelectItem value="9">9</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="12">12</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Максимальное количество эталонов для выбора (0-12)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-blue-50 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="mt-0.5 h-4 w-4 text-blue-600" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-blue-900">Как это работает:</p>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-4 text-blue-800">
|
||||
<li>Выберите максимальное количество эталонов</li>
|
||||
<li>Настройте предустановленные эталоны (опционально)</li>
|
||||
<li>При создании протокола пользователь сможет изменить выбор</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StandardsSelector
|
||||
isOpen={isStandardsDialogOpen}
|
||||
onClose={() => setIsStandardsDialogOpen(false)}
|
||||
value={safeConfig.selectedStandards}
|
||||
onChange={standardIds => {
|
||||
updateConfig({ selectedStandards: standardIds })
|
||||
}}
|
||||
maxItems={safeConfig.maxItems}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Button } from '@/component/ui/button'
|
||||
import { Label } from '@/component/ui/label'
|
||||
import { FileText, Settings } from 'lucide-react'
|
||||
import { StandardsConfig } from './definition'
|
||||
|
||||
interface StandardsPreviewProps {
|
||||
config: StandardsConfig
|
||||
}
|
||||
|
||||
const StandardSkeleton = ({ index }: { index: number }) => (
|
||||
<div className="flex items-center gap-2 rounded-md bg-muted/30 p-2 text-sm">
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<span className="text-xs font-medium text-primary">{index + 1}</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 h-3 w-3/4 rounded bg-muted"></div>
|
||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FileText className="h-2.5 w-2.5" />
|
||||
<div className="h-2 w-16 rounded bg-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-5 w-12 shrink-0 rounded bg-muted"></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const StandardsPreview: React.FC<StandardsPreviewProps> = ({
|
||||
config,
|
||||
}) => {
|
||||
const skeletonCount = config.maxItems || 7
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Эталоны</Label>
|
||||
<Button variant="outline" size="sm" disabled className="h-7 px-2">
|
||||
<Settings className="mr-1 h-3 w-3" />
|
||||
Настроить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: skeletonCount }, (_, index) => (
|
||||
<StandardSkeleton key={index} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
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 { Separator } from '@/component/ui/separator'
|
||||
import { useToast } from '@/lib/hooks/useToast'
|
||||
import {
|
||||
Check,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Search,
|
||||
Settings,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useStandards } from './hooks'
|
||||
import { Standard } from './types'
|
||||
|
||||
interface StandardsSelectorProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
maxItems?: number // Добавляем опциональный параметр
|
||||
}
|
||||
|
||||
export function StandardsSelector({
|
||||
isOpen,
|
||||
onClose,
|
||||
value = [],
|
||||
onChange,
|
||||
maxItems = 7, // Значение по умолчанию
|
||||
}: StandardsSelectorProps) {
|
||||
const [selectedStandards, setSelectedStandards] = useState<string[]>(value)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const { error } = useToast()
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { data: standards = [], isLoading, error: apiError } = useStandards()
|
||||
|
||||
// Автофокус на поле поиска при открытии
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
searchInputRef.current?.focus()
|
||||
const closeButton = document.querySelector(
|
||||
'[data-state="open"] button[aria-label="Close"]'
|
||||
)
|
||||
if (closeButton && document.activeElement === closeButton) {
|
||||
searchInputRef.current?.focus()
|
||||
}
|
||||
}, 150)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Обработка клавиш для автоматического ввода в поиск
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement
|
||||
if (
|
||||
activeElement?.tagName === 'INPUT' ||
|
||||
activeElement?.tagName === 'TEXTAREA' ||
|
||||
activeElement?.tagName === 'BUTTON' ||
|
||||
activeElement?.getAttribute('contenteditable') === 'true'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.altKey ||
|
||||
event.key === 'Tab' ||
|
||||
event.key === 'Enter' ||
|
||||
event.key === 'Escape' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault()
|
||||
setSearchTerm(prev => prev.slice(0, -1))
|
||||
searchInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key.length === 1) {
|
||||
event.preventDefault()
|
||||
setSearchTerm(prev => prev + event.key)
|
||||
searchInputRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen])
|
||||
|
||||
const filteredStandards = useMemo(() => {
|
||||
if (!searchTerm) return standards
|
||||
|
||||
const searchLower = searchTerm.toLowerCase()
|
||||
return standards.filter(
|
||||
standard =>
|
||||
standard.name.toLowerCase().includes(searchLower) ||
|
||||
standard.protocol_name.toLowerCase().includes(searchLower) ||
|
||||
standard.registry_number.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}, [searchTerm, standards])
|
||||
|
||||
const selectedStandardsData = standards.filter(standard =>
|
||||
selectedStandards.includes(standard.id)
|
||||
)
|
||||
|
||||
const unselectedStandards = filteredStandards.filter(
|
||||
standard => !selectedStandards.includes(standard.id)
|
||||
)
|
||||
|
||||
const isMaxReached = selectedStandards.length >= maxItems
|
||||
|
||||
const toggleStandard = (standardId: string) => {
|
||||
setSelectedStandards(prev => {
|
||||
if (prev.includes(standardId)) {
|
||||
return prev.filter(id => id !== standardId)
|
||||
} else if (prev.length < maxItems) {
|
||||
return [...prev, standardId]
|
||||
} else {
|
||||
error(`Можно выбрать максимум ${maxItems} эталонов`, {
|
||||
title: 'Достигнут лимит',
|
||||
})
|
||||
return prev
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const removeStandard = (standardId: string) => {
|
||||
setSelectedStandards(prev => prev.filter(id => id !== standardId))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onChange(selectedStandards)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedStandards(value)
|
||||
setSearchTerm('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const renderStandardCard = (
|
||||
standard: Standard,
|
||||
isSelected: boolean,
|
||||
showRemoveButton = false
|
||||
) => {
|
||||
const isDisabled = !isSelected && isMaxReached && !showRemoveButton
|
||||
|
||||
return (
|
||||
<div
|
||||
key={standard.id}
|
||||
className={`flex items-center gap-3 rounded-md border px-3 py-2 transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 shadow-sm'
|
||||
: isDisabled
|
||||
? 'border-border bg-muted/50 opacity-60'
|
||||
: 'border-border hover:border-primary/50 hover:bg-primary/5'
|
||||
} ${!showRemoveButton && !isDisabled ? 'cursor-pointer' : isDisabled ? 'cursor-not-allowed' : ''}`}
|
||||
onClick={
|
||||
!showRemoveButton && !isDisabled
|
||||
? () => toggleStandard(standard.id)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{!showRemoveButton && (
|
||||
<div
|
||||
className={`flex h-4 w-4 items-center justify-center rounded border transition-colors ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary'
|
||||
: isDisabled
|
||||
? 'border-muted-foreground/30 bg-muted'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<Check className="h-3 w-3 text-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`min-w-20 shrink-0 text-sm font-medium ${
|
||||
isDisabled ? 'text-muted-foreground' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{standard.protocol_name}
|
||||
</div>
|
||||
<div
|
||||
className={`truncate text-sm ${
|
||||
isDisabled
|
||||
? 'text-muted-foreground/70'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{standard.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex shrink-0 items-center gap-4 text-xs ${
|
||||
isDisabled ? 'text-muted-foreground/50' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">{standard.registry_number}</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="px-1.5 py-0.5 text-xs">
|
||||
{standard.range}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{showRemoveButton && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 shrink-0 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeStandard(standard.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="flex max-h-[95vh] max-w-6xl flex-col overflow-hidden">
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||
<p className="text-muted-foreground">Загрузка эталонов...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (apiError) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="flex max-h-[95vh] max-w-6xl flex-col overflow-hidden">
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="mb-2 text-destructive">Ошибка загрузки эталонов</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{apiError.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Badge } from '@/component/ui/badge'
|
||||
import { Button } from '@/component/ui/button'
|
||||
import { Label } from '@/component/ui/label'
|
||||
import { ElementDefinition } from '@/entitiy/element/model/interface'
|
||||
import { CellTarget } from '@/type/template'
|
||||
import { FileText, Settings, Weight } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useStandards } from './hooks'
|
||||
import { StandardsEditor } from './StandardsEditor'
|
||||
import { StandardsPreview } from './StandardsPreview'
|
||||
import { StandardsSelector } from './StandardsSelector'
|
||||
|
||||
export interface StandardsConfig {
|
||||
maxItems: number
|
||||
targetCells: CellTarget[]
|
||||
selectedStandards?: string[]
|
||||
name?: string
|
||||
}
|
||||
|
||||
// Новый интерфейс для значения элемента эталонов
|
||||
export interface StandardsValue {
|
||||
standardIds: string[]
|
||||
standardNames: string[] // пронумерованные protocol_name для записи в ячейки (1. Name, 2. Name)
|
||||
}
|
||||
|
||||
export const standardsDefinition: ElementDefinition<
|
||||
StandardsConfig,
|
||||
StandardsValue
|
||||
> = {
|
||||
type: 'standards',
|
||||
label: 'Выбор эталонов',
|
||||
icon: <Weight className="h-4 w-4" />,
|
||||
version: 1,
|
||||
defaultConfig: {
|
||||
maxItems: 7,
|
||||
targetCells: [],
|
||||
selectedStandards: [],
|
||||
name: '',
|
||||
},
|
||||
Editor: StandardsEditor,
|
||||
Preview: StandardsPreview,
|
||||
Render: ({ config, value, onChange }) => {
|
||||
const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false)
|
||||
const [selectedStandardIds, setSelectedStandardIds] = useState<string[]>(
|
||||
// Инициализируем из value.standardIds, config.selectedStandards или пустого массива
|
||||
value?.standardIds || config.selectedStandards || []
|
||||
)
|
||||
|
||||
// Получаем реальные данные эталонов
|
||||
const { data: standards = [] } = useStandards()
|
||||
|
||||
// Синхронизируем selectedStandardIds с value и config
|
||||
useEffect(() => {
|
||||
const standardIds = value?.standardIds || config.selectedStandards || []
|
||||
setSelectedStandardIds(standardIds)
|
||||
}, [value?.standardIds, config.selectedStandards])
|
||||
|
||||
// Получаем данные выбранных эталонов из реального API
|
||||
const selectedStandardsData = selectedStandardIds
|
||||
.map(id => standards.find(s => s.id === id))
|
||||
.filter(Boolean)
|
||||
|
||||
// Автоматическая инициализация onChange при загрузке данных эталонов
|
||||
useEffect(() => {
|
||||
if (standards.length > 0 && selectedStandardIds.length > 0) {
|
||||
const standardNames = selectedStandardIds
|
||||
.map((id, index) => {
|
||||
const standard = standards.find(s => s.id === id)
|
||||
return standard?.protocol_name
|
||||
? `${index + 1}. ${standard.protocol_name}`
|
||||
: ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
onChange?.({
|
||||
standardIds: selectedStandardIds,
|
||||
standardNames,
|
||||
})
|
||||
}
|
||||
}, [selectedStandardIds, standards, onChange])
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Блок с измерительными эталонами */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Эталоны</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsStandardsDialogOpen(true)}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<Settings className="mr-1 h-3 w-3" />
|
||||
Настроить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{selectedStandardsData.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{selectedStandardsData.map(
|
||||
(standard, index) =>
|
||||
standard && (
|
||||
<div
|
||||
key={standard.id}
|
||||
className="flex items-center gap-2 rounded-md bg-muted/30 p-2 text-sm"
|
||||
>
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-foreground">
|
||||
{standard.name}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FileText className="h-2.5 w-2.5" />
|
||||
<span className="hidden text-xs sm:inline">
|
||||
{standard.registry_number}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{standard.range}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 p-3 text-center text-sm text-muted-foreground">
|
||||
<Settings className="mx-auto mb-1 h-4 w-4 opacity-50" />
|
||||
Эталоны не выбраны
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StandardsSelector
|
||||
isOpen={isStandardsDialogOpen}
|
||||
onClose={() => setIsStandardsDialogOpen(false)}
|
||||
value={selectedStandardIds}
|
||||
onChange={standardIds => {
|
||||
setSelectedStandardIds(standardIds)
|
||||
|
||||
// Получаем protocol_name эталонов для выбранных ID с нумерацией
|
||||
const standardNames = standardIds
|
||||
.map((id, index) => {
|
||||
const standard = standards.find(s => s.id === id)
|
||||
return standard?.protocol_name
|
||||
? `${index + 1}. ${standard.protocol_name}`
|
||||
: ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
// Сохраняем полную информацию: ID и protocol_name
|
||||
onChange?.({
|
||||
standardIds,
|
||||
standardNames,
|
||||
})
|
||||
}}
|
||||
maxItems={config.maxItems}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
mapToCells: cfg => {
|
||||
// Возвращаем ячейки из конфигурации
|
||||
return cfg.targetCells || []
|
||||
},
|
||||
mapToCellValues: (cfg, value) => {
|
||||
// Используем targetCells из конфигурации
|
||||
const cells = cfg.targetCells || []
|
||||
|
||||
// value теперь содержит как ID, так и пронумерованные protocol_name эталонов
|
||||
const standardNames = value?.standardNames || []
|
||||
|
||||
return cells.map((target, idx) => ({
|
||||
target,
|
||||
value: standardNames[idx] || '',
|
||||
}))
|
||||
},
|
||||
}
|
||||
@@ -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}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { textDefinition } from '@/component/BasicElements'
|
||||
import { calibrationConditionsDefinition } from '@/component/BasicElements/CalibrationConditionsElement'
|
||||
import { numberDefinition } from '@/component/BasicElements/NumberElement'
|
||||
import { selectDefinition } from '@/component/BasicElements/SelectElement'
|
||||
import { standardsDefinition } from '@/component/StandardsElement/definition'
|
||||
import { verificationConditionsDefinition } from '@/component/VerificationConditionsElement/definition'
|
||||
import { verificationConditionsElementDefinition as verificationConditionsDefinition } from '@/component/VerificationConditionsElement/definition'
|
||||
import { buttonGroupDefinition } from '@/entitiy/element/model/implementations/ButtonGroup'
|
||||
import { dateDefinition } from '@/entitiy/element/model/implementations/DateElement'
|
||||
import { standardsDefinition } from '@/entitiy/element/model/implementations/StandardsElement/definition'
|
||||
import { CellTarget } from '@/type/template'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
@@ -67,7 +66,6 @@ export const elementDefinitions = {
|
||||
number: numberDefinition,
|
||||
date: dateDefinition,
|
||||
standards: standardsDefinition,
|
||||
calibration_conditions: calibrationConditionsDefinition,
|
||||
verification_conditions: verificationConditionsDefinition,
|
||||
button_group: buttonGroupDefinition,
|
||||
} as const
|
||||
@@ -80,7 +78,6 @@ export function initializeElementRegistry() {
|
||||
registerElement('number', numberDefinition)
|
||||
registerElement('date', dateDefinition)
|
||||
registerElement('button_group', buttonGroupDefinition)
|
||||
registerElement('calibration_conditions', calibrationConditionsDefinition)
|
||||
registerElement('standards', standardsDefinition)
|
||||
registerElement('verification_conditions', verificationConditionsDefinition)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user