Редизайн экрана проектов с круглыми прогрессами
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s

This commit is contained in:
poignatov
2026-01-20 16:47:57 +03:00
parent 9c97241d8d
commit f884bd3339
3 changed files with 433 additions and 122 deletions

View File

@@ -1 +1 @@
3.18.1 3.19.0

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "3.18.1", "version": "3.19.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -2,6 +2,310 @@ import ProjectProgressBar from './ProjectProgressBar'
import LoadingError from './LoadingError' import LoadingError from './LoadingError'
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils' import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
// Компонент круглого прогрессбара
function CircularProgressBar({ progress, size = 120, strokeWidth = 8, showCheckmark = true }) {
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}`
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(#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>
)}
<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" />
</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>
</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>
)
}
// Компонент карточки проекта с круглым прогрессбаром
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% прогрессбара = максимум для данного приоритета
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 // Экстра часть в процентах от полного диапазона
// Вычисляем целевую зону
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-2xl py-3 px-3 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-shrink-0 ml-3">
<ProjectCircularProgressBar
progress={visualProgress}
extraProgress={extraVisualProgress}
size={80}
strokeWidth={8}
showCheckmark={false}
percentage={goalProgress.toFixed(0)}
/>
</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 }) { function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate }) {
// Обрабатываем данные: может быть объект с projects и total, или просто массив // Обрабатываем данные: может быть объект с projects и total, или просто массив
const projectsData = data?.projects || (Array.isArray(data) ? data : []) || [] const projectsData = data?.projects || (Array.isArray(data) ? data : []) || []
@@ -46,130 +350,137 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
return Number.isFinite(numeric) ? numeric : Infinity return Number.isFinite(numeric) ? numeric : Infinity
} }
// Сортируем: сначала по priority (1, 2, ...; null в конце), затем по min_goal_score по убыванию // Группируем проекты по приоритетам
const sortedData = (projectsData && projectsData.length > 0) ? [...projectsData].sort((a, b) => { const priorityGroups = {
const priorityA = normalizePriority(a.priority) main: [], // priority === 1
const priorityB = normalizePriority(b.priority) important: [], // priority === 2
others: [] // остальные
}
if (priorityA !== priorityB) { if (projectsData && projectsData.length > 0) {
return priorityA - priorityB projectsData.forEach(project => {
} if (!project || !project.project_name) return
const minGoalA = parseFloat(a.min_goal_score) || 0 const priority = normalizePriority(project.priority)
const minGoalB = parseFloat(b.min_goal_score) || 0 if (priority === 1) {
return minGoalB - minGoalA 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 ( return (
<div> <div className="relative pt-8">
{/* Информация об общем проценте выполнения целей */} {/* Кнопка "Приоритеты" в правом верхнем углу */}
<div className="mb-3 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg p-4 border border-indigo-200"> {onNavigate && (
<div className="flex items-stretch justify-between gap-4"> <div className="absolute top-0 right-0 z-10">
<div className="min-w-0 flex-1 flex items-center gap-4"> <button
<div className="flex-1"> onClick={() => onNavigate('priorities')}
<div className="text-sm sm:text-base text-gray-600 mb-1">Выполнение целей</div> className="flex items-center justify-center w-10 h-10 text-gray-600 hover:text-indigo-600 transition-colors duration-200"
<div className="text-2xl sm:text-3xl lg:text-4xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent"> >
{hasProgressData && typeof overallProgress === 'number' && Number.isFinite(overallProgress) <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
? `${overallProgress.toFixed(1)}%` <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
: 'N/A'} <path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5Z"></path>
</div> </svg>
</div> </button>
{hasProgressData && typeof overallProgress === 'number' && Number.isFinite(overallProgress) && ( </div>
<div className="w-12 h-12 sm:w-16 sm:h-16 relative flex-shrink-0"> )}
<svg className="transform -rotate-90" viewBox="0 0 64 64">
<circle {/* Общий прогресс - большой круг в центре */}
cx="32" <div className="flex flex-col items-center mb-6">
cy="32" <div className="relative mb-6 cursor-pointer" onClick={() => onNavigate && onNavigate('full')}>
r="28" <CircularProgressBar
stroke="currentColor" progress={displayOverallProgress}
strokeWidth="6" size={180}
fill="none" strokeWidth={12}
className="text-gray-200" />
/> {/* Подсказка при наведении */}
<circle <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">
cx="32" <span className="text-xs text-gray-600 font-medium bg-white px-2 py-1 rounded shadow-sm">
cy="32" Открыть статистику
r="28" </span>
stroke="url(#gradient)"
strokeWidth="6"
fill="none"
strokeDasharray={`${Math.min(Math.max(overallProgress / 100, 0), 1) * 175.93} 175.93`}
strokeLinecap="round"
/>
{overallProgress >= 100 && (
<g className="transform rotate-90" style={{ transformOrigin: '32px 32px' }}>
<path
d="M 20 32 L 28 40 L 44 24"
stroke="url(#gradient)"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</g>
)}
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#4f46e5" />
<stop offset="100%" stopColor="#9333ea" />
</linearGradient>
</defs>
</svg>
</div>
)}
</div> </div>
{onNavigate && (
<div className="flex flex-col flex-shrink-0 gap-1" style={{ minHeight: '64px', height: '100%' }}>
<button
onClick={() => onNavigate('full')}
className="flex-1 flex items-center justify-center px-4 bg-white hover:bg-indigo-50 text-indigo-600 hover:text-indigo-700 rounded-lg border border-indigo-200 hover:border-indigo-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Статистика"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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>
</button>
<button
onClick={() => onNavigate('priorities')}
className="flex-1 flex items-center justify-center px-4 bg-white hover:bg-indigo-50 text-indigo-600 hover:text-indigo-700 rounded-lg border border-indigo-200 hover:border-indigo-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Проекты"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</button>
</div>
)}
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"> {/* Группы проектов по приоритетам */}
{sortedData.map((project, index) => { <div className="space-y-6">
if (!project || !project.project_name) { <PriorityGroup
return null title="Главный"
} subtitle={`${mainProgress.toFixed(1)}%`}
projects={priorityGroups.main}
allProjects={allProjects}
onProjectClick={onProjectClick}
/>
const projectColor = getProjectColor(project.project_name, allProjects) <PriorityGroup
title="Важные"
subtitle={`${importantProgress.toFixed(1)}%`}
projects={priorityGroups.important}
allProjects={allProjects}
onProjectClick={onProjectClick}
/>
return ( <PriorityGroup
<div key={index}> title="Остальные"
<ProjectProgressBar subtitle={`${othersProgress.toFixed(1)}%`}
projectName={project.project_name} projects={priorityGroups.others}
totalScore={parseFloat(project.total_score) || 0} allProjects={allProjects}
minGoalScore={parseFloat(project.min_goal_score) || 0} onProjectClick={onProjectClick}
maxGoalScore={parseFloat(project.max_goal_score) || 0} />
onProjectClick={onProjectClick}
projectColor={projectColor}
priority={project.priority}
/>
</div>
)
})}
</div> </div>
</div> </div>
) )