Files
play-life/play-life-web/src/components/CurrentWeek.jsx
poignatov efded0bcd2
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m9s
Улучшения UI прогрессбаров и карточек
2026-01-20 18:04:18 +03:00

466 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
// Компонент круглого прогрессбара с использованием 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)
// Если есть 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" 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"
/>
)}
{/* Иконка статистики в центре */}
<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>
<linearGradient id="project-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#4f46e5" />
<stop offset="100%" stopColor="#9333ea" />
</linearGradient>
<linearGradient id="project-extra-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<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>
</div>
)
}
// Компонент карточки проекта с круглым прогрессбаром
function ProjectCard({ project, projectColor, onProjectClick }) {
const { project_name, total_score, min_goal_score, max_goal_score, priority } = project
// Вычисляем прогресс по оригинальной логике из ProjectProgressBar
const getGoalProgress = () => {
const safeTotal = Number.isFinite(total_score) ? total_score : 0
const safeMinGoal = Number.isFinite(min_goal_score) ? min_goal_score : 0
const safeMaxGoal = Number.isFinite(max_goal_score) ? max_goal_score : 0
const normalizedPriority = (() => {
if (priority === null || priority === undefined) return null
const numeric = Number(priority)
return Number.isFinite(numeric) ? numeric : null
})()
const priorityBonus = (() => {
if (normalizedPriority === 1) return 50
if (normalizedPriority === 2) return 35
return 20
})()
// Если нет валидного minGoal, возвращаем прогресс относительно maxGoal либо 0
if (safeMinGoal <= 0) {
if (safeMaxGoal > 0) {
return Math.max(0, Math.min((safeTotal / safeMaxGoal) * 100, 100))
}
return 0
}
// До достижения minGoal растем линейно от 0 до 100%
const baseProgress = Math.max(0, Math.min((safeTotal / safeMinGoal) * 100, 100))
// Если maxGoal не задан корректно или еще не достигнут minGoal, показываем базовый прогресс
if (safeTotal < safeMinGoal || safeMaxGoal <= safeMinGoal) {
return baseProgress
}
// Между minGoal и maxGoal добавляем бонус в зависимости от приоритета
const extraRange = safeMaxGoal - safeMinGoal
const extraRatio = Math.min(1, Math.max(0, (safeTotal - safeMinGoal) / extraRange))
const extraProgress = extraRatio * priorityBonus
// Выше maxGoal прогресс не растет
return Math.min(100 + priorityBonus, 100 + extraProgress)
}
const goalProgress = getGoalProgress()
const maxProgressForPriority = 100 + (() => {
const normalizedPriority = (() => {
if (priority === null || priority === undefined) return null
const numeric = Number(priority)
return Number.isFinite(numeric) ? numeric : null
})()
if (normalizedPriority === 1) return 50
if (normalizedPriority === 2) return 35
return 20
})()
// Для визуального отображения: 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 = () => {
const safeMinGoal = Number.isFinite(min_goal_score) ? min_goal_score : 0
const safeMaxGoal = Number.isFinite(max_goal_score) ? max_goal_score : 0
if (safeMinGoal > 0 && safeMaxGoal > 0) {
return `${safeMinGoal.toFixed(0)} - ${safeMaxGoal.toFixed(0)}`
} else if (safeMinGoal > 0) {
return `${safeMinGoal.toFixed(0)}+`
}
return '0+'
}
const handleClick = () => {
if (onProjectClick) {
onProjectClick(project_name)
}
}
return (
<div
onClick={handleClick}
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-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">
<CircularProgressBar
progress={baseVisualProgress}
size={80}
strokeWidth={8}
textSize="small"
displayProgress={goalProgress}
textPosition="lower"
projectColor={projectColor}
/>
</div>
</div>
</div>
)
}
// Компонент группы проектов по приоритету
function PriorityGroup({ title, subtitle, projects, allProjects, onProjectClick }) {
if (projects.length === 0) return null
return (
<div>
{/* Заголовок группы */}
<div className="flex items-center gap-2 mb-3">
<h2 className="text-xl text-black">{title}</h2>
<span className="text-black text-xl font-bold"></span>
<span className="text-lg font-bold text-black">{subtitle}</span>
</div>
{/* Карточки проектов */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{projects.map((project, index) => {
if (!project || !project.project_name) return null
const projectColor = getProjectColor(project.project_name, allProjects)
return (
<ProjectCard
key={index}
project={project}
projectColor={projectColor}
onProjectClick={onProjectClick}
/>
)
})}
</div>
</div>
)
}
function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate }) {
// Обрабатываем данные: может быть объект с projects и total, или просто массив
const projectsData = data?.projects || (Array.isArray(data) ? data : []) || []
// Показываем loading только если данных нет и идет загрузка
if (loading && (!data || projectsData.length === 0)) {
return (
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
)
}
if (error && (!data || projectsData.length === 0)) {
return <LoadingError onRetry={onRetry} />
}
// Процент выполнения берем только из данных API
const overallProgress = (() => {
// Проверяем различные возможные названия поля
const rawValue = data?.total ?? data?.progress ?? data?.percentage ?? data?.completion ?? data?.goal_progress
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue)
if (Number.isFinite(parsedValue) && parsedValue >= 0) {
return Math.max(0, parsedValue) // Убрали ограничение на 100, так как может быть больше
}
return null // null означает, что данные не пришли
})()
const hasProgressData = overallProgress !== null
// Получаем отсортированный список всех проектов для синхронизации цветов
const allProjects = getAllProjectsSorted(allProjectsData, projectsData || [])
const normalizePriority = (value) => {
if (value === null || value === undefined) return Infinity
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : Infinity
}
// Группируем проекты по приоритетам
const priorityGroups = {
main: [], // priority === 1
important: [], // priority === 2
others: [] // остальные
}
if (projectsData && projectsData.length > 0) {
projectsData.forEach(project => {
if (!project || !project.project_name) return
const priority = normalizePriority(project.priority)
if (priority === 1) {
priorityGroups.main.push(project)
} else if (priority === 2) {
priorityGroups.important.push(project)
} else {
priorityGroups.others.push(project)
}
})
// Сортируем внутри каждой группы по min_goal_score по убыванию
Object.values(priorityGroups).forEach(group => {
group.sort((a, b) => {
const minGoalA = parseFloat(a.min_goal_score) || 0
const minGoalB = parseFloat(b.min_goal_score) || 0
return minGoalB - minGoalA
})
})
}
// Вычисляем процент выполнения для каждой группы
const calculateGroupProgress = (projects) => {
if (projects.length === 0) return 0
let totalProgress = 0
let validProjects = 0
projects.forEach(project => {
const safeTotal = Number.isFinite(project.total_score) ? project.total_score : 0
const safeMinGoal = Number.isFinite(project.min_goal_score) ? project.min_goal_score : 0
if (safeMinGoal > 0) {
const projectProgress = Math.min((safeTotal / safeMinGoal) * 100, 100)
totalProgress += projectProgress
validProjects++
}
})
return validProjects > 0 ? totalProgress / validProjects : 0
}
const mainProgress = calculateGroupProgress(priorityGroups.main)
const importantProgress = calculateGroupProgress(priorityGroups.important)
const othersProgress = calculateGroupProgress(priorityGroups.others)
// Пересчитываем общий прогресс как среднее от групповых процентов
const recalculatedOverallProgress = (() => {
const groups = []
if (priorityGroups.main.length > 0) groups.push(mainProgress)
if (priorityGroups.important.length > 0) groups.push(importantProgress)
if (priorityGroups.others.length > 0) groups.push(othersProgress)
if (groups.length === 0) return null
const average = groups.reduce((sum, progress) => sum + progress, 0) / groups.length
return Math.max(0, Math.min(average, 100)) // Ограничиваем 0-100%
})()
// Используем пересчитанный общий прогресс вместо API данных
const displayOverallProgress = recalculatedOverallProgress !== null ? recalculatedOverallProgress : (hasProgressData ? overallProgress : null)
return (
<div className="relative pt-8">
{/* Кнопка "Приоритеты" в правом верхнем углу */}
{onNavigate && (
<div className="absolute top-0 right-0 z-10">
<button
onClick={() => onNavigate('priorities')}
className="flex items-center justify-center w-10 h-10 text-gray-600 hover:text-indigo-600 transition-colors duration-200"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5Z"></path>
</svg>
</button>
</div>
)}
{/* Общий прогресс - большой круг в центре */}
<div className="flex flex-col items-center mb-6">
<div className="relative mb-6 cursor-pointer" onClick={() => onNavigate && onNavigate('full')}>
<CircularProgressBar
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">
<span className="text-xs text-gray-600 font-medium bg-white px-2 py-1 rounded shadow-sm">
Открыть статистику
</span>
</div>
</div>
</div>
{/* Группы проектов по приоритетам */}
<div className="space-y-6">
<PriorityGroup
title="Главный"
subtitle={`${mainProgress.toFixed(1)}%`}
projects={priorityGroups.main}
allProjects={allProjects}
onProjectClick={onProjectClick}
/>
<PriorityGroup
title="Важные"
subtitle={`${importantProgress.toFixed(1)}%`}
projects={priorityGroups.important}
allProjects={allProjects}
onProjectClick={onProjectClick}
/>
<PriorityGroup
title="Остальные"
subtitle={`${othersProgress.toFixed(1)}%`}
projects={priorityGroups.others}
allProjects={allProjects}
onProjectClick={onProjectClick}
/>
</div>
</div>
)
}
export default CurrentWeek