4.11.0: Редактирование экрана статистики
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m1s

This commit is contained in:
poignatov
2026-02-03 17:19:25 +03:00
parent 0c5f7fa9d9
commit c3d366b9c2
5 changed files with 219 additions and 269 deletions

View File

@@ -1,187 +1,18 @@
import React from 'react'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
import { Line } from 'react-chartjs-2'
import WeekProgressChart from './WeekProgressChart'
import LoadingError from './LoadingError'
import { getAllProjectsSorted, getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
import { getAllProjectsSorted } from '../utils/projectUtils'
import './Integrations.css'
// Экспортируем для обратной совместимости (если используется в других местах)
export { getProjectColorByIndex } from '../utils/projectUtils'
const parseWeekKey = (weekKey) => {
const [yearStr, weekStr] = weekKey.split('-W')
return { year: Number(yearStr), week: Number(weekStr) }
}
const compareWeekKeys = (a, b) => {
const [yearA, weekA] = a.split('-W').map(Number)
const [yearB, weekB] = b.split('-W').map(Number)
if (yearA !== yearB) {
return yearA - yearB
}
return weekA - weekB
}
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
)
function FullStatistics({ selectedProject, onClearSelection, data, loading, error, onRetry, currentWeekData, onNavigate }) {
const processData = () => {
if (!data || data.length === 0) return null
// Группируем данные по проектам
const projectsMap = {}
data.forEach(item => {
const projectName = item.project_name
const weekKey = `${item.report_year}-W${item.report_week.toString().padStart(2, '0')}`
if (!projectsMap[projectName]) {
projectsMap[projectName] = {}
}
projectsMap[projectName][weekKey] = parseFloat(item.total_score)
})
// Собираем все уникальные недели и сортируем их по году и неделе
const allWeeks = new Set()
Object.values(projectsMap).forEach(weeks => {
Object.keys(weeks).forEach(week => allWeeks.add(week))
})
const sortedWeeks = Array.from(allWeeks).sort(compareWeekKeys)
// Получаем отсортированный список всех проектов для синхронизации цветов
const allProjectNames = getAllProjectsSorted(data)
// Фильтруем по выбранному проекту, если он указан
let projectNames = allProjectNames.filter(name => projectsMap[name])
// Сортируем проекты так же, как на экране списка проектов (по priority и min_goal_score)
if (currentWeekData) {
projectNames = sortProjectsLikeCurrentWeek(projectNames, currentWeekData)
}
if (selectedProject) {
projectNames = projectNames.filter(name => name === selectedProject)
}
const datasets = projectNames.map((projectName) => {
let cumulativeSum = 0
const values = sortedWeeks.map(week => {
const score = projectsMap[projectName]?.[week] || 0
cumulativeSum += score
return cumulativeSum
})
// Генерируем цвет для линии на основе индекса в полном списке проектов
const color = getProjectColor(projectName, allProjectNames)
return {
label: projectName,
data: values,
borderColor: color,
backgroundColor: color.replace('50%)', '20%)'),
fill: false,
tension: 0.4,
pointRadius: 4,
pointHoverRadius: 6,
}
})
return {
labels: sortedWeeks,
datasets: datasets,
}
}
const chartData = processData()
if (error && !chartData && !loading) {
if (error && (!data || data.length === 0) && !loading) {
return <LoadingError onRetry={onRetry} />
}
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: {
position: 'bottom',
labels: {
usePointStyle: true,
padding: 15,
font: {
size: 12,
},
},
padding: {
top: 20,
},
},
title: {
display: false,
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
label: function(context) {
return `${context.dataset.label}: ${context.parsed.y.toFixed(2)}`
}
}
},
},
scales: {
x: {
display: true,
title: {
display: false,
},
grid: {
display: true,
color: 'rgba(0, 0, 0, 0.05)',
},
},
y: {
display: true,
title: {
display: false,
},
beginAtZero: true,
grid: {
display: true,
color: 'rgba(0, 0, 0, 0.05)',
},
},
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false,
},
}
return (
<div className="max-w-2xl mx-auto">
@@ -194,24 +25,19 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
</button>
)}
{loading && !chartData ? (
{loading && (!data || data.length === 0) ? (
<div className="fixed inset-0 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>
) : !chartData ? (
) : (!data || data.length === 0) ? (
<div className="flex justify-center items-center py-16">
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
</div>
) : (
<>
<div style={{ height: '550px', paddingTop: '100px' }}>
<Line data={chartData} options={chartOptions} />
</div>
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
</>
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
)}
</div>
)

View File

@@ -18,40 +18,44 @@ const compareWeekKeys = (a, b) => {
return weekA - weekB
}
// Функция для определения текущей недели в ISO формате
// ISO 8601: неделя начинается с понедельника, первая неделя года - та, в которой есть 4 января
const getCurrentWeek = () => {
const now = new Date()
const date = new Date(now.getTime())
// ISO week calculation
// Set to nearest Thursday: current date + 4 - current day number
// Make Sunday's day number 7
const day = date.getDay() || 7
date.setDate(date.getDate() + 4 - day)
// Get first day of year
const yearStart = new Date(date.getFullYear(), 0, 1)
// Calculate full weeks to nearest Thursday
const week = Math.ceil((((date - yearStart) / 86400000) + 1) / 7)
// ISO year is the year of the Thursday of that week
const year = date.getFullYear()
return { year, week }
}
function WeekProgressChart({ data, allProjectsSorted, currentWeekData, selectedProject }) {
if (!data || data.length === 0) {
return null
}
// Группируем данные по неделям
const weeksMap = {}
data.forEach(item => {
// Фильтруем по выбранному проекту, если он указан
if (selectedProject && item.project_name !== selectedProject) {
return
}
const weekKey = `${item.report_year}-W${item.report_week.toString().padStart(2, '0')}`
if (!weeksMap[weekKey]) {
weeksMap[weekKey] = []
}
weeksMap[weekKey].push({
projectName: item.project_name,
score: parseFloat(item.total_score) || 0
})
})
// Получаем все уникальные недели и сортируем их (новые сверху)
const allWeeks = Object.keys(weeksMap).sort((a, b) => -compareWeekKeys(a, b))
// Берем первые 4 недели (самые актуальные)
const last4Weeks = allWeeks.slice(0, 4)
// Определяем текущую неделю
const currentWeek = getCurrentWeek()
const currentWeekKey = formatWeekKey(currentWeek)
// Используем переданный отсортированный список проектов или получаем из данных
const allProjects = allProjectsSorted || (() => {
if (!data || data.length === 0) {
// Если нет данных, используем проекты из currentWeekData
if (currentWeekData && currentWeekData.projects) {
return currentWeekData.projects.map(p => p.project_name || p.name).filter(Boolean)
}
return []
}
const allProjectsSet = new Set()
data.forEach(item => {
allProjectsSet.add(item.project_name)
@@ -59,9 +63,91 @@ function WeekProgressChart({ data, allProjectsSorted, currentWeekData, selectedP
return Array.from(allProjectsSet).sort()
})()
// Обрабатываем данные для каждой недели
const weeksData = last4Weeks.map(weekKey => {
const weekProjects = weeksMap[weekKey]
// Группируем данные по неделям
const weeksMap = {}
if (data && data.length > 0) {
data.forEach(item => {
// Фильтруем по выбранному проекту, если он указан
if (selectedProject && item.project_name !== selectedProject) {
return
}
const weekKey = `${item.report_year}-W${item.report_week.toString().padStart(2, '0')}`
if (!weeksMap[weekKey]) {
weeksMap[weekKey] = []
}
weeksMap[weekKey].push({
projectName: item.project_name,
score: parseFloat(item.total_score) || 0
})
})
}
// Добавляем данные текущей недели из currentWeekData, если они отсутствуют в data
if (currentWeekData && currentWeekData.projects && !weeksMap[currentWeekKey]) {
const projects = Array.isArray(currentWeekData.projects)
? currentWeekData.projects
: (currentWeekData.projects?.projects || [])
weeksMap[currentWeekKey] = projects.map(project => ({
projectName: project.project_name || project.name,
score: parseFloat(project.total_score || project.score || 0)
})).filter(p => {
// Фильтруем по выбранному проекту, если он указан
if (selectedProject && p.projectName !== selectedProject) {
return false
}
return true
})
}
// Получаем все уникальные недели и сортируем их (новые сверху)
const allWeeks = Object.keys(weeksMap).sort((a, b) => -compareWeekKeys(a, b))
// Находим индекс текущей недели
const currentWeekIndex = allWeeks.findIndex(w => w === currentWeekKey)
// Разделяем недели на группы
// allWeeks отсортированы от новых к старым (индекс 0 - самая новая)
// Если currentWeekIndex = 2:
// - allWeeks[0, 1] - более новые недели (будущие, если есть)
// - allWeeks[2] - текущая неделя
// - allWeeks[3, 4, ...] - более старые недели (прошлые)
let last4Weeks = [] // Последние 4 недели ДО текущей (более старые)
let next4Weeks = [] // Следующие 4 недели ПОСЛЕ текущей (более новые, если есть)
let currentWeekInData = null
if (currentWeekIndex >= 0) {
// Текущая неделя найдена в данных
// Берем 4 недели ДО текущей (более старые) - это индексы после currentWeekIndex
last4Weeks = allWeeks.slice(currentWeekIndex + 1, currentWeekIndex + 5)
// Берем 4 недели ПОСЛЕ текущей (более новые) - это индексы до currentWeekIndex
next4Weeks = allWeeks.slice(Math.max(0, currentWeekIndex - 4), currentWeekIndex)
// Текущая неделя
currentWeekInData = currentWeekKey
} else {
// Текущая неделя не найдена в данных, но мы её добавили из currentWeekData
if (weeksMap[currentWeekKey]) {
// Добавляем текущую неделю в начало списка (она самая новая)
const weeksWithCurrent = [currentWeekKey, ...allWeeks]
// Последние 4 недели - это первые 4 из старых данных (более старые)
last4Weeks = allWeeks.slice(0, 4)
// Следующие недели после текущей - их нет, так как текущая самая новая
next4Weeks = []
currentWeekInData = currentWeekKey
} else {
// Если текущей недели нет вообще, просто берем последние 4 (самые новые из доступных)
last4Weeks = allWeeks.slice(0, 4)
next4Weeks = []
}
}
// Функция для обработки данных недели
const processWeekData = (weekKey) => {
const weekProjects = weeksMap[weekKey] || []
const totalScore = weekProjects.reduce((sum, p) => sum + p.score, 0)
// Используем абсолютные значения (баллы)
@@ -93,64 +179,99 @@ function WeekProgressChart({ data, allProjectsSorted, currentWeekData, selectedP
year,
week,
projects: sortedProjectsWithData,
totalScore
totalScore,
isCurrent: weekKey === currentWeekKey
}
})
}
// Обрабатываем данные для всех недель
const currentWeekDataProcessed = currentWeekInData ? processWeekData(currentWeekInData) : null
// Объединяем все недели кроме текущей
// next4Weeks уже отсортированы от новых к старым (индексы 0..currentWeekIndex-1)
// last4Weeks уже отсортированы от новых к старым (индексы currentWeekIndex+1..)
// Для отображения: старые сверху, новые снизу - нужно перевернуть порядок
// Объединяем: сначала более старые (last4Weeks), потом более новые (next4Weeks)
const allOtherWeeks = [...last4Weeks, ...next4Weeks]
// Переворачиваем, чтобы старые были сверху, новые снизу
const allOtherWeeksData = allOtherWeeks.map(processWeekData).reverse()
// Объединяем все недели для расчета максимального значения
const allWeeksData = [...(currentWeekDataProcessed ? [currentWeekDataProcessed] : []), ...allOtherWeeksData]
// Находим максимальное значение среди всех недель для единой шкалы сравнения
const maxTotalScore = Math.max(...weeksData.map(w => w.totalScore), 1)
const maxTotalScore = Math.max(...allWeeksData.map(w => w.totalScore), 1)
if (weeksData.length === 0) {
if (allWeeksData.length === 0) {
return null
}
// Компонент для отображения прогрессбара недели
const WeekProgressBar = ({ weekData }) => (
<div className="flex items-center gap-3">
<div className="min-w-[100px] text-sm font-medium text-gray-700">
Неделя {weekData.week}
</div>
<div className="flex-1 relative h-6 bg-gray-200 rounded-full overflow-hidden shadow-inner">
{weekData.totalScore === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-xs">
Нет данных
</div>
) : (
<>
{weekData.projects.map((project, index) => {
// Вычисляем позицию и ширину для каждого сегмента на основе абсолютных значений
let left = 0
for (let i = 0; i < index; i++) {
left += weekData.projects[i].score
}
const widthPercent = (project.score / maxTotalScore) * 100
const leftPercent = (left / maxTotalScore) * 100
return (
<div
key={project.projectName}
className="absolute h-full transition-all duration-300 hover:opacity-90"
style={{
left: `${leftPercent}%`,
width: `${widthPercent}%`,
backgroundColor: project.color,
}}
title={`${project.projectName}: ${project.score.toFixed(1)} баллов`}
/>
)
})}
</>
)}
</div>
<div className="min-w-[60px] text-right text-sm text-gray-600 font-medium">
{weekData.totalScore > 0 ? `${weekData.totalScore.toFixed(1)}` : '-'}
</div>
</div>
)
return (
<div className="mt-8">
<h2 className="text-xl font-semibold text-gray-800 mb-4">Последние 4 недели</h2>
<h2 className="text-2xl font-semibold text-gray-800 mb-6">Прогресс недель</h2>
<div className="space-y-3">
{weeksData.map((weekData) => (
<div key={weekData.weekKey} className="flex items-center gap-3">
<div className="min-w-[100px] text-sm font-medium text-gray-700">
Неделя {weekData.week}
</div>
<div className="flex-1 relative h-6 bg-gray-200 rounded-full overflow-hidden shadow-inner">
{weekData.totalScore === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-xs">
Нет данных
</div>
) : (
<>
{weekData.projects.map((project, index) => {
// Вычисляем позицию и ширину для каждого сегмента на основе абсолютных значений
let left = 0
for (let i = 0; i < index; i++) {
left += weekData.projects[i].score
}
const widthPercent = (project.score / maxTotalScore) * 100
const leftPercent = (left / maxTotalScore) * 100
return (
<div
key={project.projectName}
className="absolute h-full transition-all duration-300 hover:opacity-90"
style={{
left: `${leftPercent}%`,
width: `${widthPercent}%`,
backgroundColor: project.color,
}}
title={`${project.projectName}: ${project.score.toFixed(1)} баллов`}
/>
)
})}
</>
)}
</div>
<div className="min-w-[60px] text-right text-sm text-gray-600 font-medium">
{weekData.totalScore > 0 ? `${weekData.totalScore.toFixed(1)}` : '-'}
</div>
</div>
{/* Остальные недели (старые сверху, новые снизу) */}
{allOtherWeeksData.map((weekData) => (
<WeekProgressBar key={weekData.weekKey} weekData={weekData} />
))}
{/* Разделитель перед текущей неделей */}
{currentWeekDataProcessed && (
<div className="border-t border-gray-300 my-3"></div>
)}
{/* Текущая неделя (последняя) */}
{currentWeekDataProcessed && (
<>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Текущая неделя</h3>
<WeekProgressBar weekData={currentWeekDataProcessed} />
</>
)}
</div>
</div>
)