2026-01-20 20:40:38 +03:00
|
|
|
|
import React from 'react'
|
2025-12-29 20:01:55 +03:00
|
|
|
|
import ProjectProgressBar from './ProjectProgressBar'
|
2026-01-11 15:51:28 +03:00
|
|
|
|
import LoadingError from './LoadingError'
|
2025-12-29 20:01:55 +03:00
|
|
|
|
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
2026-01-20 18:04:18 +03:00
|
|
|
|
import { CircularProgressbar, buildStyles } from 'react-circular-progressbar'
|
|
|
|
|
|
import 'react-circular-progressbar/dist/styles.css'
|
2025-12-29 20:01:55 +03:00
|
|
|
|
|
2026-01-20 18:04:18 +03:00
|
|
|
|
// Компонент круглого прогрессбара с использованием 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%)
|
2026-01-20 16:47:57 +03:00
|
|
|
|
const normalizedProgress = Math.min(Math.max(progress || 0, 0), 100)
|
2026-01-20 18:04:18 +03:00
|
|
|
|
|
|
|
|
|
|
// Если есть 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
|
2026-01-20 16:47:57 +03:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-20 18:04:18 +03:00
|
|
|
|
<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"
|
2026-01-20 16:47:57 +03:00
|
|
|
|
/>
|
2026-01-20 18:04:18 +03:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Иконка статистики в центре */}
|
2026-01-20 16:47:57 +03:00
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
2026-01-20 18:04:18 +03:00
|
|
|
|
<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' } : {}}>
|
2026-01-20 16:47:57 +03:00
|
|
|
|
<div className="text-center">
|
2026-01-20 18:04:18 +03:00
|
|
|
|
<div className={`${textSizeClass} font-bold`} style={{ color: isComplete ? '#10b981' : '#4f46e5' }}>
|
|
|
|
|
|
{progressToDisplay !== null && progressToDisplay !== undefined ? `${progressToDisplay.toFixed(0)}%` : 'N/A'}
|
2026-01-20 16:47:57 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-20 18:04:18 +03:00
|
|
|
|
{/* Градиенты для SVG */}
|
|
|
|
|
|
<svg width="0" height="0">
|
2026-01-20 16:47:57 +03:00
|
|
|
|
<defs>
|
2026-01-20 18:04:18 +03:00
|
|
|
|
<linearGradient id="overall-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
|
|
|
|
<stop offset="0%" stopColor="#4f46e5" />
|
|
|
|
|
|
<stop offset="100%" stopColor="#9333ea" />
|
|
|
|
|
|
</linearGradient>
|
2026-01-20 16:47:57 +03:00
|
|
|
|
<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>
|
2026-01-20 18:04:18 +03:00
|
|
|
|
<linearGradient id="success-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
|
|
|
|
<stop offset="0%" stopColor="#10b981" />
|
|
|
|
|
|
<stop offset="100%" stopColor="#059669" />
|
|
|
|
|
|
</linearGradient>
|
2026-01-20 16:47:57 +03:00
|
|
|
|
</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% прогрессбара = максимум для данного приоритета
|
2026-01-20 18:04:18 +03:00
|
|
|
|
// 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 уже показывает весь прогресс
|
2026-01-20 16:47:57 +03:00
|
|
|
|
|
|
|
|
|
|
// Вычисляем целевую зону
|
|
|
|
|
|
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}
|
2026-01-20 18:04:18 +03:00
|
|
|
|
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"
|
2026-01-20 16:47:57 +03:00
|
|
|
|
>
|
|
|
|
|
|
{/* Верхняя часть с названием и прогрессом */}
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
2026-01-20 18:04:18 +03:00
|
|
|
|
{/* Левая часть - текст (название, баллы, целевая зона) */}
|
|
|
|
|
|
<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>
|
2026-01-20 16:47:57 +03:00
|
|
|
|
|
|
|
|
|
|
{/* Правая часть - круглый прогрессбар */}
|
|
|
|
|
|
<div className="flex-shrink-0 ml-3">
|
2026-01-20 18:04:18 +03:00
|
|
|
|
<CircularProgressBar
|
|
|
|
|
|
progress={baseVisualProgress}
|
2026-01-20 16:47:57 +03:00
|
|
|
|
size={80}
|
|
|
|
|
|
strokeWidth={8}
|
2026-01-20 18:04:18 +03:00
|
|
|
|
textSize="small"
|
|
|
|
|
|
displayProgress={goalProgress}
|
|
|
|
|
|
textPosition="lower"
|
|
|
|
|
|
projectColor={projectColor}
|
2026-01-20 16:47:57 +03:00
|
|
|
|
/>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 20:01:55 +03:00
|
|
|
|
function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate }) {
|
|
|
|
|
|
// Обрабатываем данные: может быть объект с projects и total, или просто массив
|
2026-01-02 16:29:58 +03:00
|
|
|
|
const projectsData = data?.projects || (Array.isArray(data) ? data : []) || []
|
2026-01-20 16:47:57 +03:00
|
|
|
|
|
2025-12-29 20:01:55 +03:00
|
|
|
|
// Показываем loading только если данных нет и идет загрузка
|
|
|
|
|
|
if (loading && (!data || projectsData.length === 0)) {
|
|
|
|
|
|
return (
|
2026-01-11 15:32:31 +03:00
|
|
|
|
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
|
2025-12-29 20:01:55 +03:00
|
|
|
|
<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>
|
2026-01-11 15:32:31 +03:00
|
|
|
|
<div className="text-gray-600 font-medium">Загрузка...</div>
|
2025-12-29 20:01:55 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (error && (!data || projectsData.length === 0)) {
|
2026-01-11 15:51:28 +03:00
|
|
|
|
return <LoadingError onRetry={onRetry} />
|
2025-12-29 20:01:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Процент выполнения берем только из данных 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)
|
2026-01-20 16:47:57 +03:00
|
|
|
|
|
2025-12-29 20:01:55 +03:00
|
|
|
|
if (Number.isFinite(parsedValue) && parsedValue >= 0) {
|
|
|
|
|
|
return Math.max(0, parsedValue) // Убрали ограничение на 100, так как может быть больше
|
|
|
|
|
|
}
|
2026-01-20 16:47:57 +03:00
|
|
|
|
|
2025-12-29 20:01:55 +03:00
|
|
|
|
return null // null означает, что данные не пришли
|
|
|
|
|
|
})()
|
2026-01-20 16:47:57 +03:00
|
|
|
|
|
2025-12-29 20:01:55 +03:00
|
|
|
|
const hasProgressData = overallProgress !== null
|
|
|
|
|
|
|
|
|
|
|
|
// Получаем отсортированный список всех проектов для синхронизации цветов
|
2026-01-02 16:29:58 +03:00
|
|
|
|
const allProjects = getAllProjectsSorted(allProjectsData, projectsData || [])
|
2025-12-29 20:01:55 +03:00
|
|
|
|
|
|
|
|
|
|
const normalizePriority = (value) => {
|
|
|
|
|
|
if (value === null || value === undefined) return Infinity
|
|
|
|
|
|
const numeric = Number(value)
|
|
|
|
|
|
return Number.isFinite(numeric) ? numeric : Infinity
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-20 16:47:57 +03:00
|
|
|
|
// Группируем проекты по приоритетам
|
|
|
|
|
|
const priorityGroups = {
|
|
|
|
|
|
main: [], // priority === 1
|
|
|
|
|
|
important: [], // priority === 2
|
|
|
|
|
|
others: [] // остальные
|
|
|
|
|
|
}
|
2025-12-29 20:01:55 +03:00
|
|
|
|
|
2026-01-20 16:47:57 +03:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2025-12-29 20:01:55 +03:00
|
|
|
|
|
2026-01-20 16:47:57 +03:00
|
|
|
|
// Сортируем внутри каждой группы по 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
|
2026-01-20 20:21:33 +03:00
|
|
|
|
return Math.max(0, average) // Убираем ограничение на 100% для текста
|
2026-01-20 16:47:57 +03:00
|
|
|
|
})()
|
|
|
|
|
|
|
|
|
|
|
|
// Используем пересчитанный общий прогресс вместо API данных
|
|
|
|
|
|
const displayOverallProgress = recalculatedOverallProgress !== null ? recalculatedOverallProgress : (hasProgressData ? overallProgress : null)
|
2025-12-29 20:01:55 +03:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-20 16:47:57 +03:00
|
|
|
|
<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}
|
2026-01-20 18:04:18 +03:00
|
|
|
|
showCheckmark={true}
|
2026-01-20 20:21:33 +03:00
|
|
|
|
displayProgress={displayOverallProgress}
|
2026-01-20 16:47:57 +03:00
|
|
|
|
/>
|
|
|
|
|
|
{/* Подсказка при наведении */}
|
|
|
|
|
|
<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>
|
2025-12-29 20:01:55 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-20 16:47:57 +03:00
|
|
|
|
{/* Группы проектов по приоритетам */}
|
|
|
|
|
|
<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}
|
|
|
|
|
|
/>
|
2025-12-29 20:01:55 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default CurrentWeek
|
|
|
|
|
|
|