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

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",
"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",

View File

@@ -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>

View File

@@ -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 && (
<>

View File

@@ -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}
/>

View File

@@ -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 => (

View File

@@ -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>
)

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"
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"