Редизайн экрана проектов с круглыми прогрессами
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
return priorityA - priorityB
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 minGoalA = parseFloat(a.min_goal_score) || 0
|
||||||
const minGoalB = parseFloat(b.min_goal_score) || 0
|
const minGoalB = parseFloat(b.min_goal_score) || 0
|
||||||
return minGoalB - minGoalA
|
return minGoalB - minGoalA
|
||||||
}) : []
|
})
|
||||||
|
})
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Информация об общем проценте выполнения целей */}
|
|
||||||
<div className="mb-3 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg p-4 border border-indigo-200">
|
|
||||||
<div className="flex items-stretch justify-between gap-4">
|
|
||||||
<div className="min-w-0 flex-1 flex items-center gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-sm sm:text-base text-gray-600 mb-1">Выполнение целей</div>
|
|
||||||
<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)
|
|
||||||
? `${overallProgress.toFixed(1)}%`
|
|
||||||
: 'N/A'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{hasProgressData && typeof overallProgress === 'number' && Number.isFinite(overallProgress) && (
|
|
||||||
<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"
|
|
||||||
cy="32"
|
|
||||||
r="28"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="6"
|
|
||||||
fill="none"
|
|
||||||
className="text-gray-200"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="32"
|
|
||||||
cy="32"
|
|
||||||
r="28"
|
|
||||||
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>
|
|
||||||
{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 className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
|
||||||
{sortedData.map((project, index) => {
|
|
||||||
if (!project || !project.project_name) {
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectColor = getProjectColor(project.project_name, allProjects)
|
// Вычисляем процент выполнения для каждой группы
|
||||||
|
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 key={index}>
|
<div className="relative pt-8">
|
||||||
<ProjectProgressBar
|
{/* Кнопка "Приоритеты" в правом верхнем углу */}
|
||||||
projectName={project.project_name}
|
{onNavigate && (
|
||||||
totalScore={parseFloat(project.total_score) || 0}
|
<div className="absolute top-0 right-0 z-10">
|
||||||
minGoalScore={parseFloat(project.min_goal_score) || 0}
|
<button
|
||||||
maxGoalScore={parseFloat(project.max_goal_score) || 0}
|
onClick={() => onNavigate('priorities')}
|
||||||
onProjectClick={onProjectClick}
|
className="flex items-center justify-center w-10 h-10 text-gray-600 hover:text-indigo-600 transition-colors duration-200"
|
||||||
projectColor={projectColor}
|
>
|
||||||
priority={project.priority}
|
<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>
|
||||||
)
|
)}
|
||||||
})}
|
|
||||||
|
{/* Общий прогресс - большой круг в центре */}
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
{/* Подсказка при наведении */}
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user