Улучшения UI прогрессбаров и карточек
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m9s

This commit is contained in:
poignatov
2026-01-20 18:04:18 +03:00
parent f884bd3339
commit efded0bcd2
5 changed files with 140 additions and 154 deletions

View File

@@ -1,133 +1,108 @@
import ProjectProgressBar from './ProjectProgressBar'
import LoadingError from './LoadingError'
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
import { CircularProgressbar, buildStyles } from 'react-circular-progressbar'
import 'react-circular-progressbar/dist/styles.css'
// Компонент круглого прогрессбара
function CircularProgressBar({ progress, size = 120, strokeWidth = 8, showCheckmark = true }) {
// Компонент круглого прогрессбара с использованием react-circular-progressbar
function CircularProgressBar({ progress, size = 120, strokeWidth = 8, showCheckmark = true, extraProgress = null, maxProgress = 100, textSize = 'large', displayProgress = null, textPosition = 'default', projectColor = null }) {
// Нормализуем прогресс для визуализации (0-100%)
const normalizedProgress = Math.min(Math.max(progress || 0, 0), 100)
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const strokeDasharray = `${(normalizedProgress / 100) * circumference} ${circumference}`
// Если есть extra progress, вычисляем визуальный прогресс для overlay
const extraVisual = extraProgress !== null && extraProgress > 0
? Math.min((extraProgress / maxProgress) * 100, 100)
: 0
// Определяем, достигнут ли 100% или выше
const isComplete = (displayProgress !== null ? displayProgress : progress) >= 100
// Определяем градиент ID: зелёный если >= 100%, иначе по наличию extra progress
const gradientId = isComplete ? 'success-gradient' : (extraVisual > 0 ? 'project-gradient' : 'overall-gradient')
const extraGradientId = 'project-extra-gradient'
// Определяем класс размера текста
const textSizeClass = textSize === 'large' ? 'text-4xl' : textSize === 'small' ? 'text-base' : 'text-lg'
// Используем displayProgress если передан (может быть больше 100%), иначе progress
const progressToDisplay = displayProgress !== null ? displayProgress : progress
return (
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" viewBox={`0 0 ${size} ${size}`}>
{/* Фоновая окружность */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
fill="none"
className="text-gray-200"
<div className="relative" style={{ width: size, height: size }}>
<CircularProgressbar
value={normalizedProgress}
strokeWidth={strokeWidth / size * 100}
styles={buildStyles({
// Цвета
pathColor: `url(#${gradientId})`,
trailColor: '#e5e7eb',
// Анимация
pathTransitionDuration: 1,
// Размер текста (убираем встроенный)
textSize: '0px',
// Поворот, чтобы пустая часть была снизу
rotation: 0.625,
strokeLinecap: 'round',
})}
// Создаем неполный круг (270 градусов)
circleRatio={0.75}
/>
{/* Extra progress overlay (если есть) */}
{extraVisual > 0 && (
<CircularProgressbar
value={extraVisual}
strokeWidth={strokeWidth / size * 100}
styles={buildStyles({
rotation: 0.625,
strokeLinecap: 'round',
textSize: '0px',
pathColor: `url(#${extraGradientId})`,
trailColor: 'transparent',
pathTransitionDuration: 1,
})}
circleRatio={0.75}
className="absolute inset-0"
/>
{/* Прогресс окружность */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="url(#overall-gradient)"
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={strokeDasharray}
strokeLinecap="round"
className="transition-all duration-1000 ease-out"
/>
{/* Галочка при 100% */}
{showCheckmark && progress >= 100 && (
<g className="transform rotate-90" style={{ transformOrigin: `${size / 2}px ${size / 2}px` }}>
<path
d={`M ${size / 2 - 12} ${size / 2} L ${size / 2 - 4} ${size / 2 + 8} L ${size / 2 + 12} ${size / 2 - 8}`}
stroke="url(#overall-gradient)"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</g>
)}
)}
{/* Иконка статистики в центре */}
<div className="absolute inset-0 flex items-center justify-center">
<svg
width={size * 0.3}
height={size * 0.3}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{
color: isComplete ? '#10b981' : '#4f46e5'
}}
>
<line x1="18" y1="20" x2="18" y2="10"></line>
<line x1="12" y1="20" x2="12" y2="4"></line>
<line x1="6" y1="20" x2="6" y2="14"></line>
</svg>
</div>
{/* Кастомный текст снизу */}
<div className={`absolute inset-0 flex justify-center items-end ${textPosition === 'lower' ? '' : 'pb-2'}`} style={textPosition === 'lower' ? { bottom: '0.125rem' } : {}}>
<div className="text-center">
<div className={`${textSizeClass} font-bold`} style={{ color: isComplete ? '#10b981' : '#4f46e5' }}>
{progressToDisplay !== null && progressToDisplay !== undefined ? `${progressToDisplay.toFixed(0)}%` : 'N/A'}
</div>
</div>
</div>
{/* Градиенты для SVG */}
<svg width="0" height="0">
<defs>
<linearGradient id="overall-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#4f46e5" />
<stop offset="100%" stopColor="#9333ea" />
</linearGradient>
</defs>
</svg>
{/* Процент в центре */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-5xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
{progress !== null && progress !== undefined ? `${progress.toFixed(0)}%` : 'N/A'}
</div>
</div>
</div>
</div>
)
}
// Специальный компонент круглого прогрессбара с поддержкой экстра прогресса
function ProjectCircularProgressBar({ progress, extraProgress, size = 120, strokeWidth = 8, showCheckmark = true, percentage }) {
const normalizedProgress = Math.min(Math.max(progress || 0, 0), 100)
const normalizedExtraProgress = Math.min(Math.max(extraProgress || 0, 0), 100)
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const strokeDasharray = `${(normalizedProgress / 100) * circumference} ${circumference}`
const extraStrokeDasharray = normalizedExtraProgress > 0 ? `${(normalizedExtraProgress / 100) * circumference} ${circumference}` : ''
return (
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" viewBox={`0 0 ${size} ${size}`}>
{/* Фоновая окружность */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
fill="none"
className="text-gray-200"
/>
{/* Базовый прогресс окружность */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="url(#project-gradient)"
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={strokeDasharray}
strokeLinecap="round"
className="transition-all duration-1000 ease-out"
/>
{/* Экстра прогресс окружность (другой цвет) */}
{normalizedExtraProgress > 0 && (
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="url(#project-extra-gradient)"
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={extraStrokeDasharray}
strokeLinecap="round"
className="transition-all duration-1000 ease-out"
/>
)}
{/* Галочка при 100% */}
{showCheckmark && progress >= 100 && (
<g className="transform rotate-90" style={{ transformOrigin: `${size / 2}px ${size / 2}px` }}>
<path
d={`M ${size / 2 - 12} ${size / 2} L ${size / 2 - 4} ${size / 2 + 8} L ${size / 2 + 12} ${size / 2 - 8}`}
stroke="url(#project-gradient)"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</g>
)}
<defs>
<linearGradient id="project-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#4f46e5" />
<stop offset="100%" stopColor="#9333ea" />
@@ -136,18 +111,12 @@ function ProjectCircularProgressBar({ progress, extraProgress, size = 120, strok
<stop offset="0%" stopColor="#f59e0b" />
<stop offset="100%" stopColor="#d97706" />
</linearGradient>
<linearGradient id="success-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#10b981" />
<stop offset="100%" stopColor="#059669" />
</linearGradient>
</defs>
</svg>
{/* Процент в центре */}
{percentage && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
{percentage}%
</div>
</div>
</div>
)}
</div>
)
}
@@ -212,10 +181,14 @@ function ProjectCard({ project, projectColor, onProjectClick }) {
})()
// Для визуального отображения: 100% прогрессбара = максимум для данного приоритета
const visualProgress = (goalProgress / maxProgressForPriority) * 100
const baseProgress = Math.min(goalProgress, 100) // Базовая часть (0-100%)
const extraProgress = Math.max(0, goalProgress - 100) // Экстра часть (свыше 100%)
const extraVisualProgress = (extraProgress / maxProgressForPriority) * 100 // Экстра часть в процентах от полного диапазона
// visualProgress показывает процент заполнения прогрессбара (0-100%), где 100% = maxProgressForPriority
const visualProgress = Math.min((goalProgress / maxProgressForPriority) * 100, 100)
// Для extra overlay: если goalProgress > 100%, показываем extra часть
// Но визуально это уже учтено в visualProgress, так что extra overlay не нужен
// Однако если нужно показать, что достигнут максимум, можно использовать другой подход
const baseVisualProgress = visualProgress
const extraVisualProgress = 0 // Не используем extra overlay, так как visualProgress уже показывает весь прогресс
// Вычисляем целевую зону
const getTargetZone = () => {
@@ -239,32 +212,33 @@ function ProjectCard({ project, projectColor, onProjectClick }) {
return (
<div
onClick={handleClick}
className="bg-white rounded-2xl py-3 px-3 shadow-sm hover:shadow-md transition-all duration-300 cursor-pointer border border-gray-200 hover:border-indigo-300"
className="bg-white rounded-3xl py-3 px-4 shadow-sm hover:shadow-md transition-all duration-300 cursor-pointer border border-gray-200 hover:border-indigo-300"
>
{/* Верхняя часть с названием и прогрессом */}
<div className="flex items-center justify-between">
{/* Левая часть - текст (название, баллы, целевая зона) */}
<div className="flex-1 min-w-0">
<div className="text-base font-semibold text-gray-600 leading-normal truncate mb-1">
{project_name}
</div>
<div className="text-3xl font-bold text-black leading-normal mb-1">
{total_score?.toFixed(1) || '0.0'}
</div>
<div className="text-xs text-gray-500 leading-normal">
Целевая зона: {getTargetZone()}
</div>
</div>
{/* Левая часть - текст (название, баллы, целевая зона) */}
<div className="flex-1 min-w-0">
<div className="text-base font-semibold text-gray-600 leading-normal truncate mb-0.5">
{project_name}
</div>
<div className="text-3xl font-bold text-black leading-normal mb-0.5">
{total_score?.toFixed(1) || '0.0'}
</div>
<div className="text-xs text-gray-500 leading-normal">
Целевая зона: {getTargetZone()}
</div>
</div>
{/* Правая часть - круглый прогрессбар */}
<div className="flex-shrink-0 ml-3">
<ProjectCircularProgressBar
progress={visualProgress}
extraProgress={extraVisualProgress}
<CircularProgressBar
progress={baseVisualProgress}
size={80}
strokeWidth={8}
showCheckmark={false}
percentage={goalProgress.toFixed(0)}
textSize="small"
displayProgress={goalProgress}
textPosition="lower"
projectColor={projectColor}
/>
</div>
</div>
@@ -446,6 +420,7 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
progress={displayOverallProgress}
size={180}
strokeWidth={12}
showCheckmark={true}
/>
{/* Подсказка при наведении */}
<div className="absolute inset-0 rounded-full opacity-0 hover:opacity-100 transition-opacity duration-200 bg-black bg-opacity-10 flex items-center justify-center">