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 (
{/* Иконка статистики в центре */}
{/* Кастомный текст снизу */}
{progressToDisplay !== null && progressToDisplay !== undefined ? `${progressToDisplay.toFixed(0)}%` : 'N/A'}
{/* Градиенты для SVG */}
)
}
// Компонент мини-карточки желания для отображения внутри карточки проекта
function MiniWishCard({ wish, onClick, pendingScoresByProject = {} }) {
const handleClick = (e) => {
e.stopPropagation()
if (onClick) {
onClick(wish)
}
}
const cond = 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 (
{wish.image_url ? (

) : (
🎁
)}
{showUnlockPoints && (
{Math.round(remaining)}
)}
)
}
// Компонент карточки проекта с круглым прогрессбаром
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 (
{/* Верхняя часть с названием и прогрессом */}
{/* Левая часть - текст (название, баллы, целевая зона) */}
{project_name}
{total_score?.toFixed(1) || '0.0'}
{today_change !== null && today_change !== undefined && today_change !== 0 && (
({formatTodayChange(today_change)})
)}
Целевая зона: {getTargetZone()}
{/* Правая часть - круглый прогрессбар */}
{/* Горизонтальный список желаний в отдельном белом блоке */}
{hasWishes && (
{wishes.map((wish) => (
))}
)}
)
}
// Компонент группы проектов по приоритету
function PriorityGroup({ title, subtitle, projects, allProjects, onProjectClick, getWishesForProject, onWishClick, pendingScoresByProject = {} }) {
if (projects.length === 0) return null
return (
{/* Заголовок группы */}
{title}
•
{subtitle}
{/* Карточки проектов */}
{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 (
)
})}
)
}
// Компонент модального окна для добавления записи
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 = (
e.stopPropagation()}>
Добавить запись
{/* Поле ввода сообщения */}
{/* Динамические поля проект+баллы */}
{rewards.length > 0 && (
)}
{/* Кнопка отправки */}
)
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) => {
const filtered = wishes.filter(wish => {
if (wish.unlocked || wish.completed) return false
if (wish.locked_conditions_count !== 1) return false
const condition = wish.first_locked_condition
if (!condition || condition.project_id !== projectId) return false
const weeksText = condition.weeks_text
if (!weeksText) return false
return weeksText === '1 неделя' || weeksText === '<1 недели'
})
// Сортируем по сроку разблокировки (от меньшего к большему)
return filtered.sort((a, b) => {
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 (
)
}
if (error && (!data || projectsData.length === 0)) {
return
}
// Процент выполнения берем только из данных 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 (
{/* Кнопка "Приоритеты" в правом верхнем углу */}
{onNavigate && (
)}
{/* Общий прогресс - большой круг в центре */}
onNavigate && onNavigate('full')}>
{/* Подсказка при наведении */}
Открыть статистику
{/* Группы проектов по приоритетам */}
{/* Модальное окно детализации желания */}
{selectedWishlistId && (
)}
{/* Модальное окно добавления записи */}
{isAddModalOpen && (
setIsAddModalOpen(false)}
onSuccess={() => {
setIsAddModalOpen(false)
refreshData()
}}
authFetch={authFetch}
setToastMessage={setToastMessage}
/>
)}
{/* Toast уведомления */}
{toastMessage && (
setToastMessage(null)}
/>
)}
)
}
export default CurrentWeek