отображение карточек более красивое
This commit is contained in:
@@ -42,6 +42,7 @@
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^18.2.0",
|
||||
"react-animated-numbers": "^1.1.1",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-day-picker": "^9.8.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -23,15 +23,7 @@ export const CategoryFieldsSelector: React.FC<CategoryFieldsSelectorProps> = ({
|
||||
disabled = false,
|
||||
compact = false, // По умолчанию обычный режим
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
getRequiredAttributes,
|
||||
getOptionalAttributes,
|
||||
isLoading,
|
||||
} = useCategoryContext()
|
||||
|
||||
const requiredAttributes = getRequiredAttributes()
|
||||
const optionalAttributes = getOptionalAttributes()
|
||||
const { attributes, isLoading } = useCategoryContext()
|
||||
|
||||
const getAttributeValue = (attributeId: string): string => {
|
||||
const value = selectedValues.find(v => v.attributeId === attributeId)?.value
|
||||
@@ -92,29 +84,6 @@ export const CategoryFieldsSelector: React.FC<CategoryFieldsSelectorProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const renderAttributeSection = (
|
||||
attributesList: Attribute[],
|
||||
title: string,
|
||||
titleColor: string = ''
|
||||
) => {
|
||||
if (attributesList.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={compact ? 'space-y-3' : 'space-y-4'}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
className={`text-sm font-semibold ${titleColor} ${compact ? 'text-xs' : ''}`}
|
||||
>
|
||||
{title}
|
||||
</Label>
|
||||
</div>
|
||||
<div className={compact ? 'grid grid-cols-2 gap-3' : 'space-y-4'}>
|
||||
{attributesList.map(renderAttribute)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -127,17 +96,11 @@ export const CategoryFieldsSelector: React.FC<CategoryFieldsSelectorProps> = ({
|
||||
|
||||
return (
|
||||
<div className={compact ? 'space-y-4' : 'space-y-6'}>
|
||||
{/* Обязательные атрибуты */}
|
||||
{renderAttributeSection(
|
||||
requiredAttributes,
|
||||
'Обязательные атрибуты',
|
||||
'text-red-600'
|
||||
)}
|
||||
|
||||
{/* Необязательные атрибуты */}
|
||||
{renderAttributeSection(optionalAttributes, 'Дополнительные атрибуты')}
|
||||
|
||||
{attributes.length === 0 && (
|
||||
{attributes.length > 0 ? (
|
||||
<div className={compact ? 'grid grid-cols-2 gap-3' : 'space-y-4'}>
|
||||
{attributes.map(renderAttribute)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">Атрибуты не настроены</p>
|
||||
</div>
|
||||
|
||||
@@ -118,21 +118,6 @@ export const TemplateFilterSidebar: React.FC<TemplateFilterSidebarProps> = ({
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
{/* Фильтр по описанию */}
|
||||
<SidebarGroup>
|
||||
<Label className="text-sm font-medium">Поиск по описанию</Label>
|
||||
<SidebarGroupContent>
|
||||
<AutocompleteInput
|
||||
label=""
|
||||
value={filters.description}
|
||||
onValueChange={value => updateFilters({ description: value })}
|
||||
suggestions={descriptionSuggestions}
|
||||
/>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
{/* Активные фильтры по атрибутам */}
|
||||
{Object.keys(filters.attributes).length > 0 && (
|
||||
<>
|
||||
|
||||
@@ -114,7 +114,9 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
|
||||
const [localStorageEnabled, setLocalStorageEnabled] = useState(false)
|
||||
const [currentValue, setCurrentValue] = useState(value || '')
|
||||
const [isAnimating, setIsAnimating] = useState(false)
|
||||
const [rollingDigits, setRollingDigits] = useState<string[]>([])
|
||||
const [animatingDigits, setAnimatingDigits] = useState<(number | null)[]>(
|
||||
[]
|
||||
)
|
||||
const [flyingDigit, setFlyingDigit] = useState<{
|
||||
show: boolean
|
||||
x: number
|
||||
@@ -178,8 +180,6 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
|
||||
|
||||
const currentNum = parseFloat(currentValue) || 0
|
||||
const newNum = currentNum + 1
|
||||
const currentStr = currentNum.toString()
|
||||
const newStr = newNum.toString()
|
||||
|
||||
setIsAnimating(true)
|
||||
|
||||
@@ -189,164 +189,92 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
|
||||
) as HTMLElement
|
||||
if (buttonElement) {
|
||||
const rect = buttonElement.getBoundingClientRect()
|
||||
const inputElement = buttonElement
|
||||
.closest('.relative')
|
||||
?.querySelector('input')
|
||||
const inputRect = inputElement?.getBoundingClientRect()
|
||||
|
||||
if (inputRect) {
|
||||
setFlyingDigit({
|
||||
show: true,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем, увеличивается ли количество разрядов
|
||||
const isAddingDigit = newStr.length > currentStr.length
|
||||
|
||||
if (isAddingDigit) {
|
||||
// Если добавляется разряд, делаем анимацию сдвига
|
||||
setRollingDigits(currentStr.split(''))
|
||||
|
||||
// Анимация сдвига при добавлении разряда
|
||||
const duration = 800
|
||||
const startTime = Date.now()
|
||||
|
||||
const animateSlide = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
// Убираем летящую цифру через 400мс
|
||||
if (elapsed > 400) {
|
||||
setTimeout(() => {
|
||||
setFlyingDigit(prev => ({ ...prev, show: false }))
|
||||
}
|
||||
}, 400)
|
||||
|
||||
if (progress < 1) {
|
||||
// Плавное появление новой цифры слева и изменение старых
|
||||
const easeOutQuad = (t: number) => t * (2 - t)
|
||||
const smoothProgress = easeOutQuad(progress)
|
||||
// Преобразуем числа в массивы цифр
|
||||
const currentStr = currentNum.toString()
|
||||
const newStr = newNum.toString()
|
||||
|
||||
// Разбираем целевое число на цифры
|
||||
const targetDigits = newStr.split('')
|
||||
const currentDigits = currentStr.split('')
|
||||
|
||||
if (smoothProgress > 0.2) {
|
||||
// Создаем анимированный массив цифр
|
||||
const animatedDigits = targetDigits.map((targetDigit, index) => {
|
||||
if (index === 0) {
|
||||
// Первая цифра (новая) - плавно появляется
|
||||
return targetDigit
|
||||
} else {
|
||||
// Остальные цифры - плавно меняются от старых к новым
|
||||
const oldDigit = currentDigits[index - 1] || '0'
|
||||
const oldNum = parseInt(oldDigit)
|
||||
const targetNum = parseInt(targetDigit)
|
||||
|
||||
// Интерполяция между старой и новой цифрой
|
||||
const currentNum =
|
||||
oldNum + (targetNum - oldNum) * smoothProgress
|
||||
|
||||
// В конце анимации фиксируем на целевой цифре
|
||||
if (progress > 0.9) {
|
||||
return targetDigit
|
||||
}
|
||||
|
||||
return Math.round(currentNum).toString()
|
||||
}
|
||||
})
|
||||
|
||||
setRollingDigits(animatedDigits)
|
||||
}
|
||||
} else {
|
||||
// Финал
|
||||
setRollingDigits([])
|
||||
setIsAnimating(false)
|
||||
handleValueChange(newStr)
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animateSlide)
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(animateSlide)
|
||||
} else {
|
||||
// Обычная анимация для случаев без добавления разряда
|
||||
// Определяем максимальную длину для выравнивания
|
||||
const maxLength = Math.max(currentStr.length, newStr.length)
|
||||
const currentPadded = currentStr.padStart(maxLength, '0')
|
||||
const newPadded = newStr.padStart(maxLength, '0')
|
||||
const currentDigits = currentStr
|
||||
.padStart(maxLength, '0')
|
||||
.split('')
|
||||
.map(d => parseInt(d))
|
||||
const newDigits = newStr
|
||||
.padStart(maxLength, '0')
|
||||
.split('')
|
||||
.map(d => parseInt(d))
|
||||
|
||||
// Создаем массив с начальными значениями
|
||||
setRollingDigits(currentPadded.split(''))
|
||||
// Инициализируем массив анимируемых цифр
|
||||
setAnimatingDigits(currentDigits)
|
||||
|
||||
// Анимация плавного перехода цифр
|
||||
const duration = 800
|
||||
// Анимация всех цифр
|
||||
const startTime = Date.now()
|
||||
const duration = 600
|
||||
|
||||
const animateTransition = () => {
|
||||
const animateDigits = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
// Убираем летящую цифру через 400мс
|
||||
if (elapsed > 400) {
|
||||
setFlyingDigit(prev => ({ ...prev, show: false }))
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
// Используем функцию сглаживания для более плавной анимации
|
||||
// Используем easing функцию для плавности
|
||||
const easeOutQuad = (t: number) => t * (2 - t)
|
||||
const smoothProgress = easeOutQuad(progress)
|
||||
|
||||
const newRolling = currentPadded
|
||||
.split('')
|
||||
.map((oldDigit, index) => {
|
||||
const targetDigit = newPadded[index]
|
||||
// Анимируем каждую цифру отдельно
|
||||
const currentAnimatedDigits = currentDigits.map((startDigit, index) => {
|
||||
const endDigit = newDigits[index]
|
||||
|
||||
// Если цифра не изменилась, оставляем как есть
|
||||
if (oldDigit === targetDigit) {
|
||||
return oldDigit
|
||||
// Если цифра не изменилась, возвращаем исходную
|
||||
if (startDigit === endDigit) {
|
||||
return startDigit
|
||||
}
|
||||
|
||||
// Для изменившихся цифр - прямой переход с интерполяцией
|
||||
const oldNum = parseInt(oldDigit)
|
||||
const targetNum = parseInt(targetDigit)
|
||||
|
||||
// Прямая интерполяция между старой и новой цифрой
|
||||
const currentNum =
|
||||
oldNum + (targetNum - oldNum) * smoothProgress
|
||||
|
||||
// В конце анимации фиксируем на целевой цифре
|
||||
if (progress > 0.95) {
|
||||
return targetDigit
|
||||
}
|
||||
|
||||
return Math.round(currentNum).toString()
|
||||
// Интерполируем между старой и новой цифрой
|
||||
const currentDigit =
|
||||
startDigit + (endDigit - startDigit) * smoothProgress
|
||||
return Math.round(currentDigit)
|
||||
})
|
||||
|
||||
setRollingDigits(newRolling)
|
||||
} else {
|
||||
// Финал
|
||||
setRollingDigits([])
|
||||
setIsAnimating(false)
|
||||
handleValueChange(newStr)
|
||||
}
|
||||
setAnimatingDigits(currentAnimatedDigits)
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animateTransition)
|
||||
requestAnimationFrame(animateDigits)
|
||||
} else {
|
||||
setAnimatingDigits([])
|
||||
setIsAnimating(false)
|
||||
handleValueChange(newNum.toString())
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(animateTransition)
|
||||
}
|
||||
requestAnimationFrame(animateDigits)
|
||||
}
|
||||
|
||||
// Определяем отображаемое значение
|
||||
const displayValue =
|
||||
isAnimating && rollingDigits.length > 0
|
||||
? rollingDigits.join('').replace(/^0+/, '') || '0'
|
||||
: currentValue
|
||||
// Проверяем, является ли текущее значение числом
|
||||
const isNumericValue =
|
||||
!isNaN(parseFloat(currentValue)) && isFinite(parseFloat(currentValue))
|
||||
|
||||
// Функция для отображения значения с анимированными цифрами
|
||||
const getDisplayValue = () => {
|
||||
if (!isAnimating || !isNumericValue || animatingDigits.length === 0) {
|
||||
return currentValue
|
||||
}
|
||||
|
||||
// Собираем число из анимированных цифр и убираем ведущие нули
|
||||
const animatedStr = animatingDigits.join('')
|
||||
return animatedStr.replace(/^0+/, '') || '0'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative space-y-1">
|
||||
@@ -431,7 +359,7 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
placeholder={config.placeholder}
|
||||
value={displayValue}
|
||||
value={getDisplayValue()}
|
||||
onChange={e => {
|
||||
if (!isAnimating) {
|
||||
// Автоматически заменяем запятые на точки
|
||||
@@ -441,10 +369,10 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
|
||||
}}
|
||||
required={config.required}
|
||||
className={`${config.showIncrementButton ? 'pr-10' : ''} ${
|
||||
isAnimating
|
||||
? 'font-mono text-lg font-bold tracking-wider text-blue-600'
|
||||
isAnimating && isNumericValue
|
||||
? 'font-mono text-lg font-bold tracking-wider text-blue-600 transition-all duration-300'
|
||||
: ''
|
||||
} transition-all duration-300`}
|
||||
}`}
|
||||
readOnly={isAnimating}
|
||||
/>
|
||||
|
||||
|
||||
@@ -15,14 +15,12 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/component/ui/dialog'
|
||||
import { Input } from '@/component/ui/input'
|
||||
import { Label } from '@/component/ui/label'
|
||||
import { Separator } from '@/component/ui/separator'
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@/component/ui/sidebar'
|
||||
import { Textarea } from '@/component/ui/textarea'
|
||||
import {
|
||||
apiTemplateWithAttributesToTemplate,
|
||||
getTemplateWithAttributesApi,
|
||||
@@ -36,9 +34,7 @@ import {
|
||||
CheckSquare,
|
||||
Copy,
|
||||
Edit3,
|
||||
FileText,
|
||||
Plus,
|
||||
Settings2,
|
||||
Square,
|
||||
Tag,
|
||||
Trash2,
|
||||
@@ -162,16 +158,12 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className={`h-4 w-4 ${iconColor}`} />
|
||||
<CardTitle className="text-sm">Основная информация</CardTitle>
|
||||
<Type className={`h-4 w-4 ${iconColor}`} />
|
||||
<CardTitle className="text-sm">{nameLabel}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-sm font-medium">
|
||||
<Type className="h-3 w-3" />
|
||||
{nameLabel}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder={`Введите ${nameLabel.toLowerCase()}`}
|
||||
value={name}
|
||||
@@ -179,19 +171,6 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-sm font-medium">
|
||||
<FileText className="h-3 w-3" />
|
||||
{descriptionLabel}
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder={`${descriptionLabel}...`}
|
||||
value={templateDescription}
|
||||
onChange={e => onDescriptionChange(e.target.value)}
|
||||
className="min-h-[80px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -665,7 +644,7 @@ export const TemplatesOverviewPage = () => {
|
||||
<section
|
||||
className={clsx(
|
||||
'grid gap-4',
|
||||
'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5'
|
||||
'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6'
|
||||
)}
|
||||
>
|
||||
{filteredTemplates.map(t => (
|
||||
|
||||
@@ -3,19 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/component/ui/popover'
|
||||
import { Template, TemplateAttributeDetail } from '@/type/template'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Award,
|
||||
Check,
|
||||
Cog,
|
||||
Copy,
|
||||
Cpu,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Settings,
|
||||
Trash2,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
import { Check, Copy, MoreVertical, Pencil, Trash2 } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
@@ -30,26 +18,6 @@ interface TemplateCardProps {
|
||||
activeFilters?: { [attributeId: string]: string } // Добавляем пропс для активных фильтров
|
||||
}
|
||||
|
||||
// Мапинг атрибутов на иконки и определение основных атрибутов
|
||||
const getAttributeIcon = (attributeName: string) => {
|
||||
const name = attributeName.toLowerCase()
|
||||
if (name.includes('тип') || name.includes('прибор')) return Cpu
|
||||
if (name.includes('госреестр') || name.includes('реестр')) return Award
|
||||
if (name.includes('исполнение')) return Cog
|
||||
return null
|
||||
}
|
||||
|
||||
const isPrimaryAttribute = (attributeName: string) => {
|
||||
const name = attributeName.toLowerCase()
|
||||
return (
|
||||
name.includes('тип') ||
|
||||
name.includes('прибор') ||
|
||||
name.includes('госреестр') ||
|
||||
name.includes('реестр') ||
|
||||
name.includes('исполнение')
|
||||
)
|
||||
}
|
||||
|
||||
const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
template,
|
||||
selectionMode = false,
|
||||
@@ -79,32 +47,6 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
onDelete?.(template.id)
|
||||
}
|
||||
|
||||
// Определяем какие атрибуты показывать
|
||||
const getDisplayAttributes = () => {
|
||||
if (!template.attributes)
|
||||
return { primaryAttributes: [], filteredAttributes: [] }
|
||||
|
||||
const attributesWithValues = template.attributes.filter(
|
||||
attr => attr.value && attr.value.trim()
|
||||
)
|
||||
|
||||
// Находим основные атрибуты (с иконками) независимо от порядка
|
||||
const primaryAttributes = attributesWithValues.filter(attr =>
|
||||
isPrimaryAttribute(attr.attribute_name)
|
||||
)
|
||||
|
||||
// Добавляем отфильтрованные атрибуты если они не являются основными
|
||||
const filteredAttributes = attributesWithValues.filter(
|
||||
attr =>
|
||||
activeFilters[attr.attribute_id] &&
|
||||
!isPrimaryAttribute(attr.attribute_name)
|
||||
)
|
||||
|
||||
return { primaryAttributes, filteredAttributes }
|
||||
}
|
||||
|
||||
const { primaryAttributes, filteredAttributes } = getDisplayAttributes()
|
||||
|
||||
return (
|
||||
// console.log(template.excelFile),
|
||||
<Card
|
||||
@@ -112,227 +54,189 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
onClick={
|
||||
selectionMode && onToggleSelect
|
||||
? () => onToggleSelect(template.id)
|
||||
: !selectionMode && isConfigured
|
||||
? () => navigate(`/templates/${template.id}/protocols`)
|
||||
: undefined
|
||||
}
|
||||
className={clsx(
|
||||
'group relative transition-all duration-200 ease-in-out',
|
||||
'border-2 ring-2 ring-offset-1',
|
||||
selectionMode && 'hover:cursor-pointer',
|
||||
selectionMode && isSelected
|
||||
? 'border-primary bg-primary/5 shadow-md shadow-primary/20 ring-primary/30'
|
||||
: 'border-border ring-transparent',
|
||||
'group relative flex min-h-[160px] flex-col transition-all duration-200',
|
||||
(selectionMode || (!selectionMode && isConfigured)) && 'cursor-pointer',
|
||||
selectionMode &&
|
||||
isSelected &&
|
||||
'border-primary ring-2 ring-primary ring-offset-2',
|
||||
!selectionMode &&
|
||||
'hover:border-primary/50 hover:shadow-lg hover:shadow-primary/5',
|
||||
'bg-card hover:bg-card/80'
|
||||
isConfigured &&
|
||||
'hover:border-primary/50 hover:shadow-md',
|
||||
!selectionMode && !isConfigured && 'cursor-not-allowed opacity-60'
|
||||
)}
|
||||
>
|
||||
{selectionMode && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isSelected}
|
||||
aria-label={isSelected ? 'Снять выделение' : 'Выбрать'}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect?.(template.id)
|
||||
}}
|
||||
className={clsx(
|
||||
'absolute right-2 top-2 z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 bg-background transition-all',
|
||||
'absolute right-2 top-2 h-5 w-5 rounded border border-primary',
|
||||
isSelected
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border bg-background text-muted-foreground hover:border-primary',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'border-input bg-background hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{isSelected ? <Check size={14} /> : null}
|
||||
{isSelected && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
)}
|
||||
{/* Popover-меню с действиями, только если не selectionMode */}
|
||||
{!selectionMode && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
aria-label="Действия"
|
||||
className={clsx(
|
||||
'absolute right-2 top-2 z-10 flex h-6 w-6 items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-all',
|
||||
'hover:border-primary hover:text-accent-foreground',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary'
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1 h-7 w-7 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-48 p-1">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleEdit}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Копировать
|
||||
</button>
|
||||
</Button>
|
||||
<div className="mx-1 my-1 h-px bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-2 text-sm text-destructive transition-colors hover:bg-destructive hover:text-destructive-foreground"
|
||||
className="w-full justify-start text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Удалить
|
||||
</button>
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="pr-8 text-base font-medium leading-tight">
|
||||
<CardHeader className="flex-shrink-0 pb-3">
|
||||
<CardTitle className="line-clamp-3 text-base font-semibold leading-snug">
|
||||
{template.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col pt-0">
|
||||
<div className="flex-1">
|
||||
{/* Описание или пустое место для выравнивания */}
|
||||
<div className="mb-3">
|
||||
{template.description ? (
|
||||
<p className="line-clamp-1 text-sm leading-tight text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm italic leading-tight text-muted-foreground/40">
|
||||
Описание не указано
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="flex flex-1 flex-col pb-4 pt-0">
|
||||
<div className="flex-1"></div>
|
||||
|
||||
{/* Атрибуты */}
|
||||
{(primaryAttributes.length > 0 || filteredAttributes.length > 0) && (
|
||||
<div className="mb-3">
|
||||
<div className="space-y-1">
|
||||
{/* Основные атрибуты с иконками */}
|
||||
{primaryAttributes.map(attr => {
|
||||
const Icon = getAttributeIcon(attr.attribute_name)
|
||||
const isFiltered = activeFilters[attr.attribute_id]
|
||||
<div className="space-y-2">
|
||||
{/* Показываем госреестр (если не в фильтрах) */}
|
||||
{template.attributes &&
|
||||
template.attributes
|
||||
.filter(
|
||||
attr =>
|
||||
attr.value &&
|
||||
attr.value.trim() &&
|
||||
(attr.attribute_name.toLowerCase().includes('госреестр') ||
|
||||
attr.attribute_name.toLowerCase().includes('реестр')) &&
|
||||
!activeFilters[attr.attribute_id]
|
||||
)
|
||||
.slice(0, 1)
|
||||
.map(attr => (
|
||||
<div
|
||||
key={attr.attribute_id}
|
||||
className="rounded-md border bg-muted/30 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex-shrink-0 text-xs font-bold text-muted-foreground/70">
|
||||
ГРСИ
|
||||
</span>
|
||||
<div className="break-all text-right text-sm font-medium leading-tight text-foreground">
|
||||
{attr.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Показываем все отфильтрованные атрибуты */}
|
||||
{template.attributes &&
|
||||
template.attributes
|
||||
.filter(
|
||||
attr =>
|
||||
attr.value &&
|
||||
attr.value.trim() &&
|
||||
activeFilters[attr.attribute_id]
|
||||
)
|
||||
.map(attr => {
|
||||
const isRegistry =
|
||||
attr.attribute_name.toLowerCase().includes('госреестр') ||
|
||||
attr.attribute_name.toLowerCase().includes('реестр')
|
||||
|
||||
if (isRegistry) {
|
||||
// Госреестр в фильтрах показываем в специальном стиле
|
||||
return (
|
||||
<div
|
||||
key={attr.attribute_id}
|
||||
className={clsx(
|
||||
'flex items-center justify-between rounded px-2 py-0.5 text-xs',
|
||||
isFiltered
|
||||
? 'border border-primary/30 bg-primary/20'
|
||||
: 'bg-muted/30'
|
||||
)}
|
||||
className="rounded-md border border-primary/50 bg-primary/10 px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
{Icon && (
|
||||
<Icon className="mr-1.5 h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate font-medium text-foreground/80">
|
||||
{attr.attribute_name}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex-shrink-0 text-xs font-bold text-primary">
|
||||
ГРСИ
|
||||
</span>
|
||||
</div>
|
||||
<span className="ml-2 max-w-[40%] truncate text-muted-foreground">
|
||||
<div className="break-all text-right text-sm font-medium leading-tight text-foreground">
|
||||
{attr.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
} else {
|
||||
// Обычные атрибуты
|
||||
const displayName =
|
||||
attr.attribute_name.toLowerCase().includes('тип') &&
|
||||
attr.attribute_name.toLowerCase().includes('прибор')
|
||||
? 'ТИП'
|
||||
: attr.attribute_name
|
||||
|
||||
{/* Отфильтрованные атрибуты (если не входят в основные) */}
|
||||
{filteredAttributes.map(attr => (
|
||||
return (
|
||||
<div
|
||||
key={attr.attribute_id}
|
||||
className="flex items-center justify-between rounded border border-primary/30 bg-primary/20 px-2 py-0.5 text-xs"
|
||||
className="rounded-md border border-primary/50 bg-primary/10 px-3 py-2"
|
||||
>
|
||||
<span className="truncate font-medium text-foreground/80">
|
||||
{attr.attribute_name}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex-shrink-0 text-xs font-bold text-primary">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="ml-2 max-w-[60%] truncate text-muted-foreground">
|
||||
<div className="break-all text-right text-sm font-medium leading-tight text-foreground">
|
||||
{attr.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Статус шаблона */}
|
||||
{!isConfigured && (
|
||||
<div className="mt-auto">
|
||||
<div className="rounded-md bg-orange-50 px-3 py-2 text-center">
|
||||
<div className="text-xs font-medium text-orange-600">
|
||||
Требует настройки
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Даты */}
|
||||
<div className="mb-3 grid grid-cols-2 gap-2 text-xs text-muted-foreground/70">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">Создан</span>
|
||||
<span>
|
||||
{new Date(template.createdAt).toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 text-right">
|
||||
<span className="font-medium">Обновлён</span>
|
||||
<span>
|
||||
{new Date(template.updatedAt).toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div className="flex items-center gap-1.5 border-t border-border/50 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 flex-1 text-xs"
|
||||
disabled={selectionMode || !isConfigured}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
navigate(`/templates/${template.id}/protocols`)
|
||||
}}
|
||||
>
|
||||
<FileText className="mr-1 h-3 w-3" />
|
||||
{isConfigured ? 'Создать' : 'Требует настройки'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={selectionMode}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
navigate(`/templates/${template.id}/edit`)
|
||||
}}
|
||||
aria-label="Редактировать шаблон"
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={selectionMode}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
navigate(`/templates/${template.id}/elements`)
|
||||
}}
|
||||
aria-label="Настроить элементы"
|
||||
>
|
||||
<Wrench className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
36
yarn.lock
36
yarn.lock
@@ -2186,6 +2186,15 @@ fraction.js@^4.3.7:
|
||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
||||
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
||||
|
||||
framer-motion@^11.18.2:
|
||||
version "11.18.2"
|
||||
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-11.18.2.tgz#0c6bd05677f4cfd3b3bdead4eb5ecdd5ed245718"
|
||||
integrity sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==
|
||||
dependencies:
|
||||
motion-dom "^11.18.1"
|
||||
motion-utils "^11.18.1"
|
||||
tslib "^2.4.0"
|
||||
|
||||
fs.realpath@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
|
||||
@@ -2850,6 +2859,26 @@ minimist@^1.2.0, minimist@^1.2.6:
|
||||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707"
|
||||
integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==
|
||||
|
||||
motion-dom@^11.18.1:
|
||||
version "11.18.1"
|
||||
resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-11.18.1.tgz#e7fed7b7dc6ae1223ef1cce29ee54bec826dc3f2"
|
||||
integrity sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==
|
||||
dependencies:
|
||||
motion-utils "^11.18.1"
|
||||
|
||||
motion-utils@^11.18.1:
|
||||
version "11.18.1"
|
||||
resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-11.18.1.tgz#671227669833e991c55813cf337899f41327db5b"
|
||||
integrity sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==
|
||||
|
||||
motion@^11.16.0:
|
||||
version "11.18.2"
|
||||
resolved "https://registry.yarnpkg.com/motion/-/motion-11.18.2.tgz#17fb372f3ed94fc9ee1384a25a9068e9da1951e7"
|
||||
integrity sha512-JLjvFDuFr42NFtcVoMAyC2sEjnpA8xpy6qWPyzQvCloznAyQ8FIXioxWfHiLtgYhoVpfUqSWpn1h9++skj9+Wg==
|
||||
dependencies:
|
||||
framer-motion "^11.18.2"
|
||||
tslib "^2.4.0"
|
||||
|
||||
ms@^2.1.1, ms@^2.1.3:
|
||||
version "2.1.3"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||
@@ -3214,6 +3243,13 @@ re-resizable@6.11.2:
|
||||
resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.11.2.tgz#2e8f7119ca3881d5b5aea0ffa014a80e5c1252b3"
|
||||
integrity sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==
|
||||
|
||||
react-animated-numbers@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-animated-numbers/-/react-animated-numbers-1.1.1.tgz#ae5aad75f4b561043a0a7ee994041f03bf33f5d8"
|
||||
integrity sha512-Jr2vbDWjo5wW+X8wBYRBACpuKdPkLa4A2ZYfxlAR0oc/gXWVeWyAmAWzZj73276IQxycxrTL7aGn4quWV7+l2Q==
|
||||
dependencies:
|
||||
motion "^11.16.0"
|
||||
|
||||
react-beautiful-dnd@^13.1.1:
|
||||
version "13.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2"
|
||||
|
||||
Reference in New Issue
Block a user