отображение карточек более красивое

This commit is contained in:
2025-07-28 14:06:27 +03:00
parent fed745b0e3
commit bffa0a01f3
7 changed files with 262 additions and 466 deletions

View File

@@ -42,6 +42,7 @@
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-animated-numbers": "^1.1.1",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
"react-day-picker": "^9.8.0", "react-day-picker": "^9.8.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

View File

@@ -23,15 +23,7 @@ export const CategoryFieldsSelector: React.FC<CategoryFieldsSelectorProps> = ({
disabled = false, disabled = false,
compact = false, // По умолчанию обычный режим compact = false, // По умолчанию обычный режим
}) => { }) => {
const { const { attributes, isLoading } = useCategoryContext()
attributes,
getRequiredAttributes,
getOptionalAttributes,
isLoading,
} = useCategoryContext()
const requiredAttributes = getRequiredAttributes()
const optionalAttributes = getOptionalAttributes()
const getAttributeValue = (attributeId: string): string => { const getAttributeValue = (attributeId: string): string => {
const value = selectedValues.find(v => v.attributeId === attributeId)?.value 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) { if (isLoading) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -127,17 +96,11 @@ export const CategoryFieldsSelector: React.FC<CategoryFieldsSelectorProps> = ({
return ( return (
<div className={compact ? 'space-y-4' : 'space-y-6'}> <div className={compact ? 'space-y-4' : 'space-y-6'}>
{/* Обязательные атрибуты */} {attributes.length > 0 ? (
{renderAttributeSection( <div className={compact ? 'grid grid-cols-2 gap-3' : 'space-y-4'}>
requiredAttributes, {attributes.map(renderAttribute)}
'Обязательные атрибуты', </div>
'text-red-600' ) : (
)}
{/* Необязательные атрибуты */}
{renderAttributeSection(optionalAttributes, 'Дополнительные атрибуты')}
{attributes.length === 0 && (
<div className="py-4 text-center"> <div className="py-4 text-center">
<p className="text-sm text-muted-foreground">Атрибуты не настроены</p> <p className="text-sm text-muted-foreground">Атрибуты не настроены</p>
</div> </div>

View File

@@ -118,21 +118,6 @@ export const TemplateFilterSidebar: React.FC<TemplateFilterSidebarProps> = ({
<SidebarSeparator /> <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 && ( {Object.keys(filters.attributes).length > 0 && (
<> <>

View File

@@ -114,7 +114,9 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
const [localStorageEnabled, setLocalStorageEnabled] = useState(false) const [localStorageEnabled, setLocalStorageEnabled] = useState(false)
const [currentValue, setCurrentValue] = useState(value || '') const [currentValue, setCurrentValue] = useState(value || '')
const [isAnimating, setIsAnimating] = useState(false) const [isAnimating, setIsAnimating] = useState(false)
const [rollingDigits, setRollingDigits] = useState<string[]>([]) const [animatingDigits, setAnimatingDigits] = useState<(number | null)[]>(
[]
)
const [flyingDigit, setFlyingDigit] = useState<{ const [flyingDigit, setFlyingDigit] = useState<{
show: boolean show: boolean
x: number x: number
@@ -178,8 +180,6 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
const currentNum = parseFloat(currentValue) || 0 const currentNum = parseFloat(currentValue) || 0
const newNum = currentNum + 1 const newNum = currentNum + 1
const currentStr = currentNum.toString()
const newStr = newNum.toString()
setIsAnimating(true) setIsAnimating(true)
@@ -189,164 +189,92 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
) as HTMLElement ) as HTMLElement
if (buttonElement) { if (buttonElement) {
const rect = buttonElement.getBoundingClientRect() const rect = buttonElement.getBoundingClientRect()
const inputElement = buttonElement
.closest('.relative')
?.querySelector('input')
const inputRect = inputElement?.getBoundingClientRect()
if (inputRect) { setFlyingDigit({
setFlyingDigit({ show: true,
show: true, x: rect.left + rect.width / 2,
x: rect.left + rect.width / 2, y: rect.top + rect.height / 2,
y: rect.top + rect.height / 2, })
}) }
// Убираем летящую цифру через 400мс
setTimeout(() => {
setFlyingDigit(prev => ({ ...prev, show: false }))
}, 400)
// Преобразуем числа в массивы цифр
const currentStr = currentNum.toString()
const newStr = newNum.toString()
// Определяем максимальную длину для выравнивания
const maxLength = Math.max(currentStr.length, newStr.length)
const currentDigits = currentStr
.padStart(maxLength, '0')
.split('')
.map(d => parseInt(d))
const newDigits = newStr
.padStart(maxLength, '0')
.split('')
.map(d => parseInt(d))
// Инициализируем массив анимируемых цифр
setAnimatingDigits(currentDigits)
// Анимация всех цифр
const startTime = Date.now()
const duration = 600
const animateDigits = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// Используем easing функцию для плавности
const easeOutQuad = (t: number) => t * (2 - t)
const smoothProgress = easeOutQuad(progress)
// Анимируем каждую цифру отдельно
const currentAnimatedDigits = currentDigits.map((startDigit, index) => {
const endDigit = newDigits[index]
// Если цифра не изменилась, возвращаем исходную
if (startDigit === endDigit) {
return startDigit
}
// Интерполируем между старой и новой цифрой
const currentDigit =
startDigit + (endDigit - startDigit) * smoothProgress
return Math.round(currentDigit)
})
setAnimatingDigits(currentAnimatedDigits)
if (progress < 1) {
requestAnimationFrame(animateDigits)
} else {
setAnimatingDigits([])
setIsAnimating(false)
handleValueChange(newNum.toString())
} }
} }
// Проверяем, увеличивается ли количество разрядов requestAnimationFrame(animateDigits)
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) {
setFlyingDigit(prev => ({ ...prev, show: false }))
}
if (progress < 1) {
// Плавное появление новой цифры слева и изменение старых
const easeOutQuad = (t: number) => t * (2 - t)
const smoothProgress = easeOutQuad(progress)
// Разбираем целевое число на цифры
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')
// Создаем массив с начальными значениями
setRollingDigits(currentPadded.split(''))
// Анимация плавного перехода цифр
const duration = 800
const startTime = Date.now()
const animateTransition = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// Убираем летящую цифру через 400мс
if (elapsed > 400) {
setFlyingDigit(prev => ({ ...prev, show: false }))
}
if (progress < 1) {
// Используем функцию сглаживания для более плавной анимации
const easeOutQuad = (t: number) => t * (2 - t)
const smoothProgress = easeOutQuad(progress)
const newRolling = currentPadded
.split('')
.map((oldDigit, index) => {
const targetDigit = newPadded[index]
// Если цифра не изменилась, оставляем как есть
if (oldDigit === targetDigit) {
return oldDigit
}
// Для изменившихся цифр - прямой переход с интерполяцией
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()
})
setRollingDigits(newRolling)
} else {
// Финал
setRollingDigits([])
setIsAnimating(false)
handleValueChange(newStr)
}
if (progress < 1) {
requestAnimationFrame(animateTransition)
}
}
requestAnimationFrame(animateTransition)
}
} }
// Определяем отображаемое значение // Проверяем, является ли текущее значение числом
const displayValue = const isNumericValue =
isAnimating && rollingDigits.length > 0 !isNaN(parseFloat(currentValue)) && isFinite(parseFloat(currentValue))
? rollingDigits.join('').replace(/^0+/, '') || '0'
: currentValue // Функция для отображения значения с анимированными цифрами
const getDisplayValue = () => {
if (!isAnimating || !isNumericValue || animatingDigits.length === 0) {
return currentValue
}
// Собираем число из анимированных цифр и убираем ведущие нули
const animatedStr = animatingDigits.join('')
return animatedStr.replace(/^0+/, '') || '0'
}
return ( return (
<div className="relative space-y-1"> <div className="relative space-y-1">
@@ -431,7 +359,7 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
<div className="relative flex items-center"> <div className="relative flex items-center">
<Input <Input
placeholder={config.placeholder} placeholder={config.placeholder}
value={displayValue} value={getDisplayValue()}
onChange={e => { onChange={e => {
if (!isAnimating) { if (!isAnimating) {
// Автоматически заменяем запятые на точки // Автоматически заменяем запятые на точки
@@ -441,10 +369,10 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
}} }}
required={config.required} required={config.required}
className={`${config.showIncrementButton ? 'pr-10' : ''} ${ className={`${config.showIncrementButton ? 'pr-10' : ''} ${
isAnimating isAnimating && isNumericValue
? 'font-mono text-lg font-bold tracking-wider text-blue-600' ? 'font-mono text-lg font-bold tracking-wider text-blue-600 transition-all duration-300'
: '' : ''
} transition-all duration-300`} }`}
readOnly={isAnimating} readOnly={isAnimating}
/> />

View File

@@ -15,14 +15,12 @@ import {
DialogTrigger, DialogTrigger,
} from '@/component/ui/dialog' } from '@/component/ui/dialog'
import { Input } from '@/component/ui/input' import { Input } from '@/component/ui/input'
import { Label } from '@/component/ui/label'
import { Separator } from '@/component/ui/separator' import { Separator } from '@/component/ui/separator'
import { import {
SidebarInset, SidebarInset,
SidebarProvider, SidebarProvider,
SidebarTrigger, SidebarTrigger,
} from '@/component/ui/sidebar' } from '@/component/ui/sidebar'
import { Textarea } from '@/component/ui/textarea'
import { import {
apiTemplateWithAttributesToTemplate, apiTemplateWithAttributesToTemplate,
getTemplateWithAttributesApi, getTemplateWithAttributesApi,
@@ -36,9 +34,7 @@ import {
CheckSquare, CheckSquare,
Copy, Copy,
Edit3, Edit3,
FileText,
Plus, Plus,
Settings2,
Square, Square,
Tag, Tag,
Trash2, Trash2,
@@ -162,16 +158,12 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings2 className={`h-4 w-4 ${iconColor}`} /> <Type className={`h-4 w-4 ${iconColor}`} />
<CardTitle className="text-sm">Основная информация</CardTitle> <CardTitle className="text-sm">{nameLabel}</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <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 <Input
placeholder={`Введите ${nameLabel.toLowerCase()}`} placeholder={`Введите ${nameLabel.toLowerCase()}`}
value={name} value={name}
@@ -179,19 +171,6 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
className="h-9" className="h-9"
/> />
</div> </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> </CardContent>
</Card> </Card>
@@ -665,7 +644,7 @@ export const TemplatesOverviewPage = () => {
<section <section
className={clsx( className={clsx(
'grid gap-4', '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 => ( {filteredTemplates.map(t => (

View File

@@ -3,19 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
import { Popover, PopoverContent, PopoverTrigger } from '@/component/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/component/ui/popover'
import { Template, TemplateAttributeDetail } from '@/type/template' import { Template, TemplateAttributeDetail } from '@/type/template'
import clsx from 'clsx' import clsx from 'clsx'
import { import { Check, Copy, MoreVertical, Pencil, Trash2 } from 'lucide-react'
Award,
Check,
Cog,
Copy,
Cpu,
FileText,
MoreVertical,
Pencil,
Settings,
Trash2,
Wrench,
} from 'lucide-react'
import React from 'react' import React from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
@@ -30,26 +18,6 @@ interface TemplateCardProps {
activeFilters?: { [attributeId: string]: string } // Добавляем пропс для активных фильтров 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> = ({ const TemplateCard: React.FC<TemplateCardProps> = ({
template, template,
selectionMode = false, selectionMode = false,
@@ -79,32 +47,6 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
onDelete?.(template.id) 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 ( return (
// console.log(template.excelFile), // console.log(template.excelFile),
<Card <Card
@@ -112,227 +54,189 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
onClick={ onClick={
selectionMode && onToggleSelect selectionMode && onToggleSelect
? () => onToggleSelect(template.id) ? () => onToggleSelect(template.id)
: undefined : !selectionMode && isConfigured
? () => navigate(`/templates/${template.id}/protocols`)
: undefined
} }
className={clsx( className={clsx(
'group relative transition-all duration-200 ease-in-out', 'group relative flex min-h-[160px] flex-col transition-all duration-200',
'border-2 ring-2 ring-offset-1', (selectionMode || (!selectionMode && isConfigured)) && 'cursor-pointer',
selectionMode && 'hover:cursor-pointer', selectionMode &&
selectionMode && isSelected isSelected &&
? 'border-primary bg-primary/5 shadow-md shadow-primary/20 ring-primary/30' 'border-primary ring-2 ring-primary ring-offset-2',
: 'border-border ring-transparent',
!selectionMode && !selectionMode &&
'hover:border-primary/50 hover:shadow-lg hover:shadow-primary/5', isConfigured &&
'bg-card hover:bg-card/80' 'hover:border-primary/50 hover:shadow-md',
!selectionMode && !isConfigured && 'cursor-not-allowed opacity-60'
)} )}
> >
{selectionMode && ( {selectionMode && (
<button <button
type="button" type="button"
tabIndex={0}
aria-pressed={isSelected}
aria-label={isSelected ? 'Снять выделение' : 'Выбрать'}
onClick={e => { onClick={e => {
e.stopPropagation() e.stopPropagation()
onToggleSelect?.(template.id) onToggleSelect?.(template.id)
}} }}
className={clsx( 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 isSelected
? 'border-primary bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: 'border-border bg-background text-muted-foreground hover:border-primary', : 'border-input bg-background hover:bg-accent hover:text-accent-foreground'
'focus-visible:ring-2 focus-visible:ring-primary'
)} )}
> >
{isSelected ? <Check size={14} /> : null} {isSelected && <Check className="h-3 w-3" />}
</button> </button>
)} )}
{/* Popover-меню с действиями, только если не selectionMode */}
{!selectionMode && ( {!selectionMode && (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <Button
type="button" variant="ghost"
tabIndex={0} size="sm"
aria-label="Действия" className="absolute right-1 top-1 h-7 w-7 p-0 opacity-0 transition-opacity group-hover:opacity-100"
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'
)}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<MoreVertical className="h-3.5 w-3.5" /> <MoreVertical className="h-3 w-3" />
</button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" className="w-48 p-1"> <PopoverContent align="end" className="w-48 p-1">
<button <Button
type="button" variant="ghost"
size="sm"
onClick={handleEdit} 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>
<button <Button
type="button" variant="ghost"
size="sm"
onClick={handleCopy} 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" /> <div className="mx-1 my-1 h-px bg-border" />
<button <Button
type="button" variant="ghost"
size="sm"
onClick={handleDelete} 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> </PopoverContent>
</Popover> </Popover>
)} )}
<CardHeader className="pb-2"> <CardHeader className="flex-shrink-0 pb-3">
<CardTitle className="pr-8 text-base font-medium leading-tight"> <CardTitle className="line-clamp-3 text-base font-semibold leading-snug">
{template.name} {template.name}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col pt-0"> <CardContent className="flex flex-1 flex-col pb-4 pt-0">
<div className="flex-1"> <div className="flex-1"></div>
{/* Описание или пустое место для выравнивания */}
<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>
{/* Атрибуты */} <div className="space-y-2">
{(primaryAttributes.length > 0 || filteredAttributes.length > 0) && ( {/* Показываем госреестр (если не в фильтрах) */}
<div className="mb-3"> {template.attributes &&
<div className="space-y-1"> template.attributes
{/* Основные атрибуты с иконками */} .filter(
{primaryAttributes.map(attr => { attr =>
const Icon = getAttributeIcon(attr.attribute_name) attr.value &&
const isFiltered = activeFilters[attr.attribute_id] 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 ( return (
<div <div
key={attr.attribute_id} key={attr.attribute_id}
className={clsx( className="rounded-md border border-primary/50 bg-primary/10 px-3 py-2"
'flex items-center justify-between rounded px-2 py-0.5 text-xs',
isFiltered
? 'border border-primary/30 bg-primary/20'
: 'bg-muted/30'
)}
> >
<div className="flex min-w-0 flex-1 items-center"> <div className="flex items-center justify-between gap-2">
{Icon && ( <span className="flex-shrink-0 text-xs font-bold text-primary">
<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}
</span> </span>
<div className="break-all text-right text-sm font-medium leading-tight text-foreground">
{attr.value}
</div>
</div> </div>
<span className="ml-2 max-w-[40%] truncate text-muted-foreground">
{attr.value}
</span>
</div> </div>
) )
})} } else {
// Обычные атрибуты
const displayName =
attr.attribute_name.toLowerCase().includes('тип') &&
attr.attribute_name.toLowerCase().includes('прибор')
? 'ТИП'
: attr.attribute_name
{/* Отфильтрованные атрибуты (если не входят в основные) */} return (
{filteredAttributes.map(attr => ( <div
<div key={attr.attribute_id}
key={attr.attribute_id} className="rounded-md border border-primary/50 bg-primary/10 px-3 py-2"
className="flex items-center justify-between rounded border border-primary/30 bg-primary/20 px-2 py-0.5 text-xs" >
> <div className="flex items-center justify-between gap-2">
<span className="truncate font-medium text-foreground/80"> <span className="flex-shrink-0 text-xs font-bold text-primary">
{attr.attribute_name} {displayName}
</span> </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} {attr.value}
</span> </div>
</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>
)}
{/* Даты */}
<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> )}
{/* Кнопки действий */}
<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> </CardContent>
</Card> </Card>
) )

View File

@@ -2186,6 +2186,15 @@ fraction.js@^4.3.7:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== 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: fs.realpath@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" 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" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707"
integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== 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: ms@^2.1.1, ms@^2.1.3:
version "2.1.3" version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" 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" resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.11.2.tgz#2e8f7119ca3881d5b5aea0ffa014a80e5c1252b3"
integrity sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A== 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: react-beautiful-dnd@^13.1.1:
version "13.1.1" version "13.1.1"
resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2" resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2"