All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
964 lines
36 KiB
JavaScript
964 lines
36 KiB
JavaScript
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
|
||
|