Files
play-life/play-life-web/src/components/CurrentWeek.jsx
poignatov 7f51411175
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
6.3.0: Готовые желания на экране прогресса недели
2026-03-05 19:41:43 +03:00

964 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext'
import ProjectProgressBar from './ProjectProgressBar'
import LoadingError from './LoadingError'
import Toast from './Toast'
import WishlistDetail from './WishlistDetail'
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
import { CircularProgressbar, buildStyles } from 'react-circular-progressbar'
import 'react-circular-progressbar/dist/styles.css'
import './CurrentWeek.css'
// Компонент круглого прогрессбара с использованием react-circular-progressbar
function CircularProgressBar({ progress, size = 120, strokeWidth = 8, showCheckmark = true, textSize = 'large', displayProgress = null, textPosition = 'default', projectColor = null }) {
// Нормализуем прогресс для визуализации (0-100%)
const normalizedProgress = Math.min(Math.max(progress || 0, 0), 100)
// Определяем, достигнут ли 100% или выше
const isComplete = (displayProgress !== null ? displayProgress : progress) >= 100
// Определяем градиент ID: зелёный если >= 100%, иначе обычный градиент
const gradientId = isComplete ? 'success-gradient' : 'overall-gradient'
// Определяем класс размера текста
const textSizeClass = textSize === 'large' ? 'text-4xl' : textSize === 'small' ? 'text-base' : 'text-lg'
// Используем displayProgress если передан (может быть больше 100%), иначе progress
const progressToDisplay = displayProgress !== null ? displayProgress : progress
return (
<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}
/>
{/* Иконка статистики в центре */}
<div className="absolute inset-0 flex items-center justify-center">
<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' } : {}}>
<div className="text-center">
<div className={`${textSizeClass} font-bold`} style={{ color: isComplete ? '#10b981' : '#4f46e5' }}>
{progressToDisplay !== null && progressToDisplay !== undefined ? `${progressToDisplay.toFixed(0)}%` : 'N/A'}
</div>
</div>
</div>
{/* Градиенты для SVG */}
<svg width="0" height="0">
<defs>
<linearGradient id="overall-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#4f46e5" />
<stop offset="100%" stopColor="#9333ea" />
</linearGradient>
<linearGradient id="success-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#10b981" />
<stop offset="100%" stopColor="#059669" />
</linearGradient>
</defs>
</svg>
</div>
)
}
// Компонент мини-карточки желания для отображения внутри карточки проекта
function MiniWishCard({ wish, onClick, pendingScoresByProject = {} }) {
const handleClick = (e) => {
e.stopPropagation()
if (onClick) {
onClick(wish)
}
}
// Желание помечено как готовое на бэкенде
const isReady = wish.is_ready === true
// Для готовых желаний берём условие из unlock_conditions, иначе из first_locked_condition
const cond = isReady
? wish.unlock_conditions?.find(c => c.type === 'project_points')
: wish.first_locked_condition
const isPointsCondition = cond?.type === 'project_points'
const required = cond?.required_points ?? 0
const current = cond?.current_points ?? 0
const projectId = cond?.project_id
const pending = (projectId != null && pendingScoresByProject[projectId] != null) ? Number(pendingScoresByProject[projectId]) : 0
const remaining = isPointsCondition ? (required - current - pending) : 0
const showUnlockPoints = remaining > 0
// Auto-size: уменьшаем шрифт при большом количестве цифр, чтобы текст влезал
const digits = String(Math.round(remaining)).length
const fontSizePx = digits <= 1 ? 22 : digits === 2 ? 19 : digits === 3 ? 16 : 14
return (
<div className="mini-wish-card" onClick={handleClick}>
<div className={`mini-wish-image ${isReady ? 'ready' : ''}`}>
{wish.image_url ? (
<img src={wish.image_url} alt={wish.name} />
) : (
<div className="mini-wish-placeholder">🎁</div>
)}
{!isReady && <div className="mini-wish-overlay" aria-hidden="true" />}
{showUnlockPoints && !isReady && (
<div
className="mini-wish-unlock-points"
style={{ fontSize: `${fontSizePx}px` }}
aria-hidden="true"
>
{Math.round(remaining)}
</div>
)}
</div>
</div>
)
}
// Компонент карточки желания в виде строки (для отображения 1-2 желаний)
function WishRowCard({ wish, onClick, pendingScoresByProject = {}, position, minGoalScore }) {
const handleClick = (e) => {
e.stopPropagation()
if (onClick) {
onClick(wish)
}
}
// Желание помечено как готовое на бэкенде
const isReady = wish.is_ready === true
// Для готовых желаний берём условие из unlock_conditions, иначе из first_locked_condition
const cond = isReady
? wish.unlock_conditions?.find(c => c.type === 'project_points')
: wish.first_locked_condition
const isPointsCondition = cond?.type === 'project_points'
const required = cond?.required_points ?? 0
const current = cond?.current_points ?? 0
const projectId = cond?.project_id
const pending = (projectId != null && pendingScoresByProject[projectId] != null) ? Number(pendingScoresByProject[projectId]) : 0
const remaining = isPointsCondition ? (required - current - pending) : 0
const formatDaysText = (days) => {
if (days < 1) return '<1 дня'
const daysRounded = Math.round(days)
const lastDigit = daysRounded % 10
const lastTwoDigits = daysRounded % 100
let dayWord
if (lastTwoDigits >= 11 && lastTwoDigits <= 14) {
dayWord = 'дней'
} else if (lastDigit === 1) {
dayWord = 'день'
} else if (lastDigit >= 2 && lastDigit <= 4) {
dayWord = 'дня'
} else {
dayWord = 'дней'
}
return `${daysRounded} ${dayWord}`
}
const getUnlockText = () => {
if (isReady) {
return 'Готово!'
}
if (remaining <= 0) {
return 'скоро'
}
const pointsText = `${Math.round(remaining)} баллов`
const safeMinGoal = Number.isFinite(minGoalScore) && minGoalScore > 0 ? minGoalScore : 0
if (safeMinGoal > 0) {
const weeks = remaining / safeMinGoal
const days = weeks * 7
return `${pointsText} (${formatDaysText(days)})`
}
return pointsText
}
const positionClass = position === 'last' ? 'wish-row-card-last' : 'wish-row-card-middle'
return (
<div className={`wish-row-card ${positionClass}`} onClick={handleClick}>
<div className="wish-row-image">
{wish.image_url ? (
<img src={wish.image_url} alt={wish.name} />
) : (
<div className="wish-row-placeholder">🎁</div>
)}
{!isReady && <div className="wish-row-overlay" aria-hidden="true" />}
</div>
<div className="wish-row-info">
<div className="wish-row-title">{wish.name}</div>
<div className={`wish-row-unlock ${isReady ? 'ready' : ''}`}>{getUnlockText()}</div>
</div>
</div>
)
}
// Компонент карточки проекта с круглым прогрессбаром
function ProjectCard({ project, projectColor, onProjectClick, wishes = [], onWishClick, pendingScoresByProject = {} }) {
const { project_name, total_score, min_goal_score, max_goal_score, priority, today_change } = 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()
// Для визуального отображения: круг показывает максимум 100%
const visualProgress = Math.min(goalProgress, 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 formatTodayChange = (value) => {
if (value === null || value === undefined) return '0'
const rounded = Math.round(value * 10) / 10
if (rounded === 0) return '0'
if (Number.isInteger(rounded)) {
return rounded > 0 ? `+${rounded}` : `${rounded}`
}
return rounded > 0 ? `+${rounded.toFixed(1)}` : `${rounded.toFixed(1)}`
}
const handleClick = () => {
if (onProjectClick) {
onProjectClick(project_name)
}
}
const hasWishes = wishes && wishes.length > 0
return (
<div className="project-card-wrapper">
<div
onClick={handleClick}
className={`project-card-inner bg-white py-3 px-4 transition-all duration-300 cursor-pointer ${hasWishes ? 'rounded-t-3xl project-card-inner-with-wishes' : 'rounded-3xl'}`}
>
{/* Верхняя часть с названием и прогрессом */}
<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-0.5">
{project_name}
</div>
<div className="flex items-center gap-2 mb-0.5">
<div className="text-3xl font-bold text-black leading-normal">
{total_score?.toFixed(1) || '0.0'}
</div>
{today_change !== null && today_change !== undefined && today_change !== 0 && (
<div className="text-base font-medium text-gray-400 leading-normal">
({formatTodayChange(today_change)})
</div>
)}
</div>
<div className="text-xs text-gray-500 leading-normal">
Целевая зона: {getTargetZone()}
</div>
</div>
{/* Правая часть - круглый прогрессбар */}
<div className="flex-shrink-0 ml-3">
<CircularProgressBar
progress={visualProgress}
size={80}
strokeWidth={8}
textSize="small"
displayProgress={goalProgress}
textPosition="lower"
projectColor={projectColor}
/>
</div>
</div>
</div>
{/* Список желаний: горизонтальный скролл для 3+, вертикальный список для 1-2 */}
{hasWishes && (
wishes.length >= 3 ? (
<div className="project-wishes-block">
<div className="project-wishes-scroll">
{wishes.map((wish) => (
<MiniWishCard
key={wish.id}
wish={wish}
onClick={onWishClick}
pendingScoresByProject={pendingScoresByProject || {}}
/>
))}
</div>
</div>
) : (
<div className="project-wishes-vertical">
{wishes.map((wish, index) => {
const isLast = index === wishes.length - 1
const position = isLast ? 'last' : 'middle'
return (
<WishRowCard
key={wish.id}
wish={wish}
onClick={onWishClick}
pendingScoresByProject={pendingScoresByProject || {}}
position={position}
minGoalScore={min_goal_score}
/>
)
})}
</div>
)
)}
</div>
)
}
// Компонент группы проектов по приоритету
function PriorityGroup({ title, subtitle, projects, allProjects, onProjectClick, getWishesForProject, onWishClick, pendingScoresByProject = {} }) {
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, project.color)
const projectWishes = getWishesForProject ? getWishesForProject(project.project_id) : []
return (
<ProjectCard
key={index}
project={project}
projectColor={projectColor}
onProjectClick={onProjectClick}
wishes={projectWishes}
onWishClick={onWishClick}
pendingScoresByProject={pendingScoresByProject}
/>
)
})}
</div>
</div>
)
}
// Компонент модального окна для добавления записи
function AddEntryModal({ onClose, onSuccess, authFetch, setToastMessage }) {
const [message, setMessage] = useState('')
const [rewards, setRewards] = useState([])
const [projects, setProjects] = useState([])
const [isSending, setIsSending] = useState(false)
const debounceTimer = useRef(null)
// Загрузка списка проектов для автокомплита
useEffect(() => {
const loadProjects = async () => {
try {
const response = await authFetch('/projects')
if (response.ok) {
const data = await response.json()
setProjects(Array.isArray(data) ? data : [])
}
} catch (err) {
console.error('Error loading projects:', err)
}
}
loadProjects()
}, [authFetch])
// Функция поиска максимального индекса плейсхолдера
const findMaxPlaceholderIndex = (msg) => {
if (!msg) return -1
const indices = []
// Ищем ${N}
const matchesCurly = msg.match(/\$\{(\d+)\}/g) || []
matchesCurly.forEach(match => {
const numMatch = match.match(/\d+/)
if (numMatch) indices.push(parseInt(numMatch[0]))
})
// Ищем $N (но не \$N)
let searchIndex = 0
while (true) {
const index = msg.indexOf('$', searchIndex)
if (index === -1) break
if (index === 0 || msg[index - 1] !== '\\') {
const afterDollar = msg.substring(index + 1)
const digitMatch = afterDollar.match(/^(\d+)/)
if (digitMatch) {
indices.push(parseInt(digitMatch[0]))
}
}
searchIndex = index + 1
}
return indices.length > 0 ? Math.max(...indices) : -1
}
// Пересчет rewards при изменении сообщения (debounce 500ms)
useEffect(() => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
debounceTimer.current = setTimeout(() => {
const maxIndex = findMaxPlaceholderIndex(message)
setRewards(prevRewards => {
const currentRewards = [...prevRewards]
// Удаляем лишние
while (currentRewards.length > maxIndex + 1) {
currentRewards.pop()
}
// Добавляем недостающие
while (currentRewards.length < maxIndex + 1) {
currentRewards.push({
position: currentRewards.length,
project_name: '',
value: '0'
})
}
return currentRewards
})
}, 500)
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
}
}, [message])
const handleRewardChange = (index, field, value) => {
const newRewards = [...rewards]
newRewards[index] = { ...newRewards[index], [field]: value }
setRewards(newRewards)
}
// Формирование финального сообщения с заменой плейсхолдеров
const buildFinalMessage = () => {
let result = message
// Формируем строки замены для каждого reward
const rewardStrings = {}
rewards.forEach((reward, index) => {
const score = parseFloat(reward.value) || 0
const projectName = reward.project_name.trim()
if (!projectName) return
const scoreStr = score >= 0
? `**${projectName}+${score}**`
: `**${projectName}${score}**`
rewardStrings[index] = scoreStr
})
// Заменяем ${N}
for (let i = 0; i < 100; i++) {
const placeholder = `\${${i}}`
if (rewardStrings[i]) {
result = result.split(placeholder).join(rewardStrings[i])
}
}
// Заменяем $N (с конца, чтобы $10 не заменился раньше $1)
for (let i = 99; i >= 0; i--) {
if (rewardStrings[i]) {
const regex = new RegExp(`\\$${i}(?!\\d)`, 'g')
result = result.replace(regex, rewardStrings[i])
}
}
return result
}
// Проверка валидности формы: все поля проект+баллы должны быть заполнены
const isFormValid = () => {
if (rewards.length === 0) return true // Если нет полей, форма валидна
return rewards.every(reward => {
const projectName = reward.project_name?.trim() || ''
const value = reward.value?.toString().trim() || ''
return projectName !== '' && value !== ''
})
}
const handleSubmit = async () => {
// Валидация: все проекты должны быть заполнены
for (const reward of rewards) {
if (!reward.project_name.trim()) {
setToastMessage({ text: 'Заполните все проекты', type: 'error' })
return
}
}
const finalMessage = buildFinalMessage()
if (!finalMessage.trim()) {
setToastMessage({ text: 'Введите сообщение', type: 'error' })
return
}
setIsSending(true)
try {
const response = await authFetch('/message/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: finalMessage })
})
if (!response.ok) {
throw new Error('Ошибка при отправке')
}
setToastMessage({ text: 'Запись добавлена', type: 'success' })
onSuccess()
} catch (err) {
console.error('Error sending message:', err)
setToastMessage({ text: err.message || 'Ошибка при отправке', type: 'error' })
} finally {
setIsSending(false)
}
}
const modalContent = (
<div className="add-entry-modal-overlay" onClick={onClose}>
<div className="add-entry-modal" onClick={(e) => e.stopPropagation()}>
<div className="add-entry-modal-header">
<h2 className="add-entry-modal-title">Добавить запись</h2>
<button onClick={onClose} className="add-entry-close-button"></button>
</div>
<div className="add-entry-modal-content">
{/* Поле ввода сообщения */}
<div className="add-entry-field">
<label className="add-entry-label">Сообщение</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Используйте $0, $1 для указания проектов"
className="add-entry-textarea"
rows={3}
/>
</div>
{/* Динамические поля проект+баллы */}
{rewards.length > 0 && (
<div className="add-entry-rewards">
{rewards.map((reward, index) => (
<div key={index} className="add-entry-reward-item">
<span className="add-entry-reward-number">{index}</span>
<input
type="text"
value={reward.project_name}
onChange={(e) => handleRewardChange(index, 'project_name', e.target.value)}
placeholder="Проект"
className="add-entry-input add-entry-project-input"
list={`add-entry-projects-${index}`}
/>
<datalist id={`add-entry-projects-${index}`}>
{projects.map(p => (
<option key={p.project_id} value={p.project_name} />
))}
</datalist>
<input
type="number"
step="any"
value={reward.value}
onChange={(e) => handleRewardChange(index, 'value', e.target.value)}
placeholder="Баллы"
className="add-entry-input add-entry-score-input"
/>
</div>
))}
</div>
)}
{/* Кнопка отправки */}
<button
onClick={handleSubmit}
disabled={isSending || !isFormValid()}
className="add-entry-submit-button"
>
{isSending ? 'Отправка...' : 'Отправить'}
</button>
</div>
</div>
</div>
)
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent
}
function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate, onOpenAddModal }) {
const { authFetch } = useAuth()
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
const [selectedWishlistId, setSelectedWishlistId] = useState(null)
// Желания и pending-баллы по проектам приходят вместе с данными
const wishes = data?.wishes || []
const pendingScoresByProject = data?.pending_scores_by_project && typeof data.pending_scores_by_project === 'object' ? data.pending_scores_by_project : {}
// Функция для получения числового значения срока из текста
const getWeeksValue = (weeksText) => {
if (!weeksText) return Infinity
if (weeksText === '<1 недели') return 0
if (weeksText === '1 неделя') return 1
const match = weeksText.match(/(\d+)/)
return match ? parseInt(match[1], 10) : Infinity
}
// Функция фильтрации желаний для проекта
// Фильтрация уже выполнена на бэкенде, здесь только группируем по проекту
const getWishesForProject = (projectId) => {
// Вспомогательная функция для получения projectId из желания
const getWishProjectId = (wish) => {
// Сначала пробуем first_locked_condition
if (wish.first_locked_condition?.project_id) {
return wish.first_locked_condition.project_id
}
// Иначе ищем в unlock_conditions (для готовых/разблокированных желаний)
if (wish.unlock_conditions) {
const cond = wish.unlock_conditions.find(c => c.type === 'project_points')
return cond?.project_id
}
return null
}
const filtered = wishes.filter(wish => {
return getWishProjectId(wish) === projectId
})
// Сортируем: готовые желания первыми, затем по сроку разблокировки
return filtered.sort((a, b) => {
// Готовые желания показываем первыми
if (a.is_ready && !b.is_ready) return -1
if (!a.is_ready && b.is_ready) return 1
const weeksA = getWeeksValue(a.first_locked_condition?.weeks_text)
const weeksB = getWeeksValue(b.first_locked_condition?.weeks_text)
return weeksA - weeksB
})
}
// Обработчик клика на желание
const handleWishClick = (wish) => {
setSelectedWishlistId(wish.id)
}
// Закрытие модального окна детализации желания
const handleCloseWishDetail = () => {
setSelectedWishlistId(null)
}
// Экспортируем функцию открытия модала для использования из App.jsx
useEffect(() => {
if (onOpenAddModal) {
const openFn = () => {
setIsAddModalOpen(true)
}
onOpenAddModal(openFn)
}
}, [onOpenAddModal])
// Функция для обновления данных после добавления записи
const refreshData = () => {
if (onRetry) {
onRetry()
}
}
// Обрабатываем данные: может быть объект с projects и total, или просто массив
const projectsData = data?.projects || (Array.isArray(data) ? data : []) || []
// Показываем loading только если данных нет и идет загрузка
if (loading && (!data || projectsData.length === 0)) {
return (
<div className="fixed inset-0 bottom-20 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>
)
}
if (error && (!data || projectsData.length === 0)) {
return <LoadingError onRetry={onRetry} />
}
// Процент выполнения берем только из данных 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)
if (Number.isFinite(parsedValue) && parsedValue >= 0) {
return Math.max(0, parsedValue) // Убрали ограничение на 100, так как может быть больше
}
return null // null означает, что данные не пришли
})()
const hasProgressData = overallProgress !== null
// Получаем отсортированный список всех проектов для синхронизации цветов
const allProjects = getAllProjectsSorted(allProjectsData, projectsData || [])
const normalizePriority = (value) => {
if (value === null || value === undefined) return Infinity
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : Infinity
}
// Группируем проекты по приоритетам
const priorityGroups = {
main: [], // priority === 1
important: [], // priority === 2
others: [] // остальные
}
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 minGoalB = parseFloat(b.min_goal_score) || 0
return minGoalB - minGoalA
})
})
}
// Получаем проценты групп из API данных
const mainProgress = (() => {
const rawValue = data?.group_progress_1
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue)
return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : 0
})()
const importantProgress = (() => {
const rawValue = data?.group_progress_2
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue)
return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : 0
})()
const othersProgress = (() => {
const rawValue = data?.group_progress_0
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue)
return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : 0
})()
// Используем общий прогресс из API данных
const displayOverallProgress = overallProgress
return (
<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 !== null ? Math.min(displayOverallProgress, 100) : 0}
size={180}
strokeWidth={12}
showCheckmark={true}
displayProgress={displayOverallProgress}
/>
{/* Подсказка при наведении */}
<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" style={{ paddingBottom: '5rem' }}>
<PriorityGroup
title="Главный"
subtitle={`${Math.round(mainProgress)}%`}
projects={priorityGroups.main}
allProjects={allProjects}
onProjectClick={onProjectClick}
getWishesForProject={getWishesForProject}
onWishClick={handleWishClick}
pendingScoresByProject={pendingScoresByProject}
/>
<PriorityGroup
title="Важные"
subtitle={`${Math.round(importantProgress)}%`}
projects={priorityGroups.important}
allProjects={allProjects}
onProjectClick={onProjectClick}
getWishesForProject={getWishesForProject}
onWishClick={handleWishClick}
pendingScoresByProject={pendingScoresByProject}
/>
<PriorityGroup
title="Остальные"
subtitle={`${Math.round(othersProgress)}%`}
projects={priorityGroups.others}
allProjects={allProjects}
onProjectClick={onProjectClick}
getWishesForProject={getWishesForProject}
onWishClick={handleWishClick}
pendingScoresByProject={pendingScoresByProject}
/>
</div>
{/* Модальное окно детализации желания */}
{selectedWishlistId && (
<WishlistDetail
wishlistId={selectedWishlistId}
onNavigate={onNavigate}
onClose={handleCloseWishDetail}
onRefresh={refreshData}
/>
)}
{/* Модальное окно добавления записи */}
{isAddModalOpen && (
<AddEntryModal
onClose={() => setIsAddModalOpen(false)}
onSuccess={() => {
setIsAddModalOpen(false)
refreshData()
}}
authFetch={authFetch}
setToastMessage={setToastMessage}
/>
)}
{/* Toast уведомления */}
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default CurrentWeek