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 ? ( {wish.name} ) : (
🎁
)}
) } // Компонент карточки желания в виде строки (для отображения 1-2 желаний) function WishRowCard({ wish, onClick, pendingScoresByProject = {}, position }) { 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 weeksText = cond?.weeks_text || '' const getUnlockText = () => { if (remaining <= 0) { return 'скоро' } const pointsText = `${Math.round(remaining)} баллов` if (weeksText) { return `${pointsText} (${weeksText})` } return pointsText } const positionClass = position === 'last' ? 'wish-row-card-last' : 'wish-row-card-middle' return (
{wish.image_url ? ( {wish.name} ) : (
🎁
)}
{wish.name}
{getUnlockText()}
) } // Компонент карточки проекта с круглым прогрессбаром 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()}
{/* Правая часть - круглый прогрессбар */}
{/* Список желаний: горизонтальный скролл для 3+, вертикальный список для 1-2 */} {hasWishes && ( wishes.length >= 3 ? (
{wishes.map((wish) => ( ))}
) : (
{wishes.map((wish, index) => { const isLast = index === wishes.length - 1 const position = isLast ? 'last' : 'middle' return ( ) })}
) )}
) } // Компонент группы проектов по приоритету 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()}>

Добавить запись

{/* Поле ввода сообщения */}