Улучшения UI прогрессбаров и карточек
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m9s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m9s
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user