Initial commit
This commit is contained in:
289
play-life-web/src/components/FullStatistics.jsx
Normal file
289
play-life-web/src/components/FullStatistics.jsx
Normal file
@@ -0,0 +1,289 @@
|
||||
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 { getAllProjectsSorted, getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
|
||||
|
||||
// Экспортируем для обратной совместимости (если используется в других местах)
|
||||
export { getProjectColorByIndex } from '../utils/projectUtils'
|
||||
|
||||
const formatWeekKey = ({ year, week }) => `${year}-W${week.toString().padStart(2, '0')}`
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Возвращает понедельник ISO-недели
|
||||
const getDateOfISOWeek = (week, year) => {
|
||||
const simple = new Date(year, 0, 1 + (week - 1) * 7)
|
||||
const dayOfWeek = simple.getDay() || 7 // Sunday -> 7
|
||||
if (dayOfWeek !== 1) {
|
||||
simple.setDate(simple.getDate() + (1 - dayOfWeek))
|
||||
}
|
||||
simple.setHours(0, 0, 0, 0)
|
||||
return simple
|
||||
}
|
||||
|
||||
const getISOWeekInfo = (date) => {
|
||||
const target = new Date(date.getTime())
|
||||
target.setHours(0, 0, 0, 0)
|
||||
target.setDate(target.getDate() + 4 - (target.getDay() || 7))
|
||||
const year = target.getFullYear()
|
||||
const yearStart = new Date(year, 0, 1)
|
||||
const week = Math.ceil(((target - yearStart) / 86400000 + 1) / 7)
|
||||
return { year, week }
|
||||
}
|
||||
|
||||
const getPrevWeekKey = (weekKey) => {
|
||||
const { year, week } = parseWeekKey(weekKey)
|
||||
const currentWeekDate = getDateOfISOWeek(week, year)
|
||||
const prevWeekDate = new Date(currentWeekDate.getTime())
|
||||
prevWeekDate.setDate(prevWeekDate.getDate() - 7)
|
||||
const prevWeekInfo = getISOWeekInfo(prevWeekDate)
|
||||
return formatWeekKey(prevWeekInfo)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// Добавляем дополнительную неделю со значением 0,
|
||||
// если первая неделя проекта имеет ненулевое значение
|
||||
Object.values(projectsMap).forEach((weeks) => {
|
||||
const projectWeeks = Object.keys(weeks)
|
||||
if (!projectWeeks.length) return
|
||||
|
||||
const sortedProjectWeeks = projectWeeks.sort(compareWeekKeys)
|
||||
const firstWeekKey = sortedProjectWeeks[0]
|
||||
const firstScore = weeks[firstWeekKey]
|
||||
|
||||
if (firstScore !== 0) {
|
||||
const zeroWeekKey = getPrevWeekKey(firstWeekKey)
|
||||
weeks[zeroWeekKey] = 0
|
||||
}
|
||||
})
|
||||
|
||||
// Собираем все уникальные недели и сортируем их по году и неделе
|
||||
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()
|
||||
|
||||
// Показываем loading только если данных нет и идет загрузка
|
||||
if (loading && !chartData) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
<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 && !chartData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-4 max-w-md">
|
||||
<div className="text-red-700 font-semibold mb-2">Ошибка загрузки</div>
|
||||
<div className="text-red-600 text-sm">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl font-semibold"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
if (!chartData) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{onNavigate && (
|
||||
<div className="flex justify-end mb-4">
|
||||
<button
|
||||
onClick={() => onNavigate('current')}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-full bg-white hover:bg-gray-100 text-gray-600 hover:text-gray-800 border border-gray-200 hover:border-gray-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="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ height: '550px' }}>
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FullStatistics
|
||||
|
||||
Reference in New Issue
Block a user