All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 55s
417 lines
17 KiB
JavaScript
417 lines
17 KiB
JavaScript
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||
import { useAuth } from './auth/AuthContext'
|
||
import './TaskDetail.css'
|
||
|
||
const API_URL = '/api/tasks'
|
||
|
||
// Функция для проверки, является ли период нулевым
|
||
const isZeroPeriod = (intervalStr) => {
|
||
if (!intervalStr) return false
|
||
const trimmed = intervalStr.trim()
|
||
const parts = trimmed.split(/\s+/)
|
||
if (parts.length < 1) return false
|
||
const value = parseInt(parts[0], 10)
|
||
return !isNaN(value) && value === 0
|
||
}
|
||
|
||
// Функция для проверки, является ли repetition_date нулевым
|
||
const isZeroDate = (dateStr) => {
|
||
if (!dateStr) return false
|
||
const trimmed = dateStr.trim()
|
||
const parts = trimmed.split(/\s+/)
|
||
if (parts.length < 2) return false
|
||
const value = parts[0]
|
||
const numValue = parseInt(value, 10)
|
||
return !isNaN(numValue) && numValue === 0
|
||
}
|
||
|
||
// Функция для форматирования числа как %.4g в Go (до 4 значащих цифр)
|
||
const formatScore = (num) => {
|
||
if (num === 0) return '0'
|
||
|
||
// Используем toPrecision(4) для получения до 4 значащих цифр
|
||
let str = num.toPrecision(4)
|
||
|
||
// Убираем лишние нули в конце (но оставляем точку если есть цифры после неё)
|
||
str = str.replace(/\.?0+$/, '')
|
||
|
||
// Если получилась экспоненциальная нотация для больших чисел, конвертируем обратно
|
||
if (str.includes('e+') || str.includes('e-')) {
|
||
const numValue = parseFloat(str)
|
||
// Для чисел >= 10000 используем экспоненциальную нотацию
|
||
if (Math.abs(numValue) >= 10000) {
|
||
return str
|
||
}
|
||
// Для остальных конвертируем в обычное число
|
||
return numValue.toString().replace(/\.?0+$/, '')
|
||
}
|
||
|
||
return str
|
||
}
|
||
|
||
// Функция для формирования сообщения Telegram в реальном времени
|
||
const formatTelegramMessage = (task, rewards, subtasks, selectedSubtasks, progressionValue) => {
|
||
if (!task) return ''
|
||
|
||
// Вычисляем score для каждой награды основной задачи
|
||
const rewardStrings = {}
|
||
const progressionBase = task.progression_base
|
||
const hasProgression = progressionBase != null
|
||
// Если прогрессия не введена - используем progression_base
|
||
const value = progressionValue && progressionValue.trim() !== ''
|
||
? parseFloat(progressionValue)
|
||
: (hasProgression ? progressionBase : null)
|
||
|
||
rewards.forEach(reward => {
|
||
let score = reward.value
|
||
if (reward.use_progression && hasProgression) {
|
||
if (value !== null && !isNaN(value)) {
|
||
score = (value / progressionBase) * reward.value
|
||
} else {
|
||
// Если прогрессия не введена, используем progression_base (score = reward.value)
|
||
score = reward.value
|
||
}
|
||
}
|
||
|
||
const scoreStr = score >= 0
|
||
? `**${reward.project_name}+${formatScore(score)}**`
|
||
: `**${reward.project_name}-${formatScore(Math.abs(score))}**`
|
||
rewardStrings[reward.position] = scoreStr
|
||
})
|
||
|
||
// Функция для замены плейсхолдеров
|
||
const replacePlaceholders = (message, rewardStrings) => {
|
||
let result = message
|
||
// Сначала защищаем экранированные плейсхолдеры
|
||
const escapedMarkers = {}
|
||
for (let i = 0; i < 100; i++) {
|
||
const escaped = `\\$${i}`
|
||
const marker = `__ESCAPED_DOLLAR_${i}__`
|
||
if (result.includes(escaped)) {
|
||
escapedMarkers[marker] = escaped
|
||
result = result.replace(new RegExp(escaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), marker)
|
||
}
|
||
}
|
||
// Заменяем ${0}, ${1}, и т.д.
|
||
for (let i = 0; i < 100; i++) {
|
||
const placeholder = `\${${i}}`
|
||
if (rewardStrings[i]) {
|
||
result = result.replace(new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), rewardStrings[i])
|
||
}
|
||
}
|
||
// Заменяем $0, $1, и т.д. (с конца, чтобы не заменить $1 в $10)
|
||
for (let i = 99; i >= 0; i--) {
|
||
if (rewardStrings[i]) {
|
||
const searchStr = `$${i}`
|
||
const regex = new RegExp(`\\$${i}(?!\\d)`, 'g')
|
||
result = result.replace(regex, rewardStrings[i])
|
||
}
|
||
}
|
||
// Восстанавливаем экранированные
|
||
Object.entries(escapedMarkers).forEach(([marker, escaped]) => {
|
||
result = result.replace(new RegExp(marker, 'g'), escaped)
|
||
})
|
||
return result
|
||
}
|
||
|
||
// Формируем сообщение основной задачи
|
||
let mainTaskMessage = task.reward_message && task.reward_message.trim() !== ''
|
||
? replacePlaceholders(task.reward_message, rewardStrings)
|
||
: task.name
|
||
|
||
// Формируем сообщения подзадач
|
||
const subtaskMessages = []
|
||
subtasks.forEach(subtask => {
|
||
if (!selectedSubtasks.has(subtask.task.id)) return
|
||
if (!subtask.task.reward_message || subtask.task.reward_message.trim() === '') return
|
||
|
||
// Вычисляем score для наград подзадачи
|
||
const subtaskRewardStrings = {}
|
||
subtask.rewards.forEach(reward => {
|
||
let score = reward.value
|
||
const subtaskProgressionBase = subtask.task.progression_base
|
||
if (reward.use_progression) {
|
||
if (subtaskProgressionBase != null && value !== null && !isNaN(value)) {
|
||
score = (value / subtaskProgressionBase) * reward.value
|
||
} else if (hasProgression && value !== null && !isNaN(value)) {
|
||
score = (value / progressionBase) * reward.value
|
||
} else if (subtaskProgressionBase != null) {
|
||
// Если прогрессия не введена, используем progression_base подзадачи (score = reward.value)
|
||
score = reward.value
|
||
} else if (hasProgression) {
|
||
// Если у подзадачи нет progression_base, используем основной (score = reward.value)
|
||
score = reward.value
|
||
}
|
||
}
|
||
|
||
const scoreStr = score >= 0
|
||
? `**${reward.project_name}+${formatScore(score)}**`
|
||
: `**${reward.project_name}-${formatScore(Math.abs(score))}**`
|
||
subtaskRewardStrings[reward.position] = scoreStr
|
||
})
|
||
|
||
const subtaskMessage = replacePlaceholders(subtask.task.reward_message, subtaskRewardStrings)
|
||
subtaskMessages.push(subtaskMessage)
|
||
})
|
||
|
||
// Формируем итоговое сообщение
|
||
let finalMessage = mainTaskMessage
|
||
subtaskMessages.forEach(subtaskMsg => {
|
||
finalMessage += '\n + ' + subtaskMsg
|
||
})
|
||
|
||
return finalMessage
|
||
}
|
||
|
||
function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
||
const { authFetch } = useAuth()
|
||
const [taskDetail, setTaskDetail] = useState(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState(null)
|
||
const [selectedSubtasks, setSelectedSubtasks] = useState(new Set())
|
||
const [progressionValue, setProgressionValue] = useState('')
|
||
const [isCompleting, setIsCompleting] = useState(false)
|
||
|
||
const fetchTaskDetail = useCallback(async () => {
|
||
try {
|
||
setLoading(true)
|
||
setError(null)
|
||
const response = await authFetch(`${API_URL}/${taskId}`)
|
||
if (!response.ok) {
|
||
throw new Error('Ошибка загрузки задачи')
|
||
}
|
||
const data = await response.json()
|
||
setTaskDetail(data)
|
||
} catch (err) {
|
||
setError(err.message)
|
||
console.error('Error fetching task detail:', err)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [taskId, authFetch])
|
||
|
||
useEffect(() => {
|
||
if (taskId) {
|
||
fetchTaskDetail()
|
||
} else {
|
||
// Сбрасываем состояние при закрытии модального окна
|
||
setTaskDetail(null)
|
||
setLoading(true)
|
||
setError(null)
|
||
setSelectedSubtasks(new Set())
|
||
setProgressionValue('')
|
||
}
|
||
}, [taskId, fetchTaskDetail])
|
||
|
||
const handleSubtaskToggle = (subtaskId) => {
|
||
setSelectedSubtasks(prev => {
|
||
const newSet = new Set(prev)
|
||
if (newSet.has(subtaskId)) {
|
||
newSet.delete(subtaskId)
|
||
} else {
|
||
newSet.add(subtaskId)
|
||
}
|
||
return newSet
|
||
})
|
||
}
|
||
|
||
const handleComplete = async (shouldDelete = false) => {
|
||
if (!taskDetail) return
|
||
|
||
// Если прогрессия не введена, используем 0 (валидация не требуется)
|
||
|
||
setIsCompleting(true)
|
||
try {
|
||
const payload = {
|
||
children_task_ids: Array.from(selectedSubtasks)
|
||
}
|
||
|
||
// Если есть прогрессия, отправляем значение (или progression_base, если не введено)
|
||
if (taskDetail.task.progression_base != null) {
|
||
if (progressionValue.trim()) {
|
||
payload.value = parseFloat(progressionValue)
|
||
if (isNaN(payload.value)) {
|
||
throw new Error('Неверное значение')
|
||
}
|
||
} else {
|
||
// Если прогрессия не введена - используем progression_base
|
||
payload.value = taskDetail.task.progression_base
|
||
}
|
||
}
|
||
|
||
// Используем единую ручку для выполнения и удаления
|
||
const endpoint = shouldDelete
|
||
? `${API_URL}/${taskId}/complete-and-delete`
|
||
: `${API_URL}/${taskId}/complete`
|
||
|
||
const response = await authFetch(endpoint, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(payload),
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({}))
|
||
throw new Error(errorData.message || 'Ошибка при выполнении задачи')
|
||
}
|
||
|
||
// Показываем уведомление о выполнении
|
||
if (onTaskCompleted) {
|
||
onTaskCompleted()
|
||
}
|
||
|
||
// Обновляем список и закрываем модальное окно
|
||
if (onRefresh) {
|
||
onRefresh()
|
||
}
|
||
if (onClose) {
|
||
onClose()
|
||
}
|
||
} catch (err) {
|
||
console.error('Error completing task:', err)
|
||
alert(err.message || 'Ошибка при выполнении задачи')
|
||
} finally {
|
||
setIsCompleting(false)
|
||
}
|
||
}
|
||
|
||
if (!taskId) return null
|
||
|
||
const { task, rewards, subtasks } = taskDetail || {}
|
||
const hasProgression = task?.progression_base != null
|
||
// Кнопка всегда активна (если прогрессия не введена, используем 0)
|
||
const canComplete = true
|
||
|
||
// Определяем, является ли задача одноразовой
|
||
// Одноразовая задача: когда оба поля null/undefined (из бэкенда видно, что в этом случае задача помечается как deleted)
|
||
// Бесконечная задача: когда хотя бы одно поле равно "0 day" или "0 week" и т.д.
|
||
// Повторяющаяся задача: когда есть значение (не null и не 0)
|
||
// Кнопка "Закрыть" показывается для задач, которые НЕ одноразовые (имеют повторение, даже если оно равно 0)
|
||
// Проверяем, что оба поля отсутствуют (null или undefined)
|
||
const isOneTime = (task?.repetition_period == null || task?.repetition_period === undefined) &&
|
||
(task?.repetition_date == null || task?.repetition_date === undefined)
|
||
|
||
// Формируем сообщение для Telegram в реальном времени
|
||
const telegramMessage = useMemo(() => {
|
||
if (!taskDetail) return ''
|
||
return formatTelegramMessage(task, rewards || [], subtasks || [], selectedSubtasks, progressionValue)
|
||
}, [taskDetail, task, rewards, subtasks, selectedSubtasks, progressionValue])
|
||
|
||
return (
|
||
<div className="task-detail-modal-overlay" onClick={onClose}>
|
||
<div className="task-detail-modal" onClick={(e) => e.stopPropagation()}>
|
||
<div className="task-detail-modal-header">
|
||
<h2 className="task-detail-title">
|
||
{loading ? 'Загрузка...' : error ? 'Ошибка' : taskDetail ? task.name : 'Задача'}
|
||
</h2>
|
||
<button onClick={onClose} className="task-detail-close-button">
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div className="task-detail-modal-content">
|
||
{loading && (
|
||
<div className="loading">Загрузка...</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="error-message">{error}</div>
|
||
)}
|
||
|
||
{!loading && !error && taskDetail && (
|
||
<>
|
||
{/* Поле ввода прогрессии */}
|
||
{hasProgression && (
|
||
<div className="progression-section">
|
||
<label className="progression-label">Значение прогрессии</label>
|
||
<input
|
||
type="number"
|
||
step="any"
|
||
value={progressionValue}
|
||
onChange={(e) => setProgressionValue(e.target.value)}
|
||
placeholder={task.progression_base?.toString() || ''}
|
||
className="progression-input"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Список подзадач */}
|
||
{subtasks && subtasks.length > 0 && (
|
||
<div className="task-subtasks">
|
||
{subtasks.map((subtask) => {
|
||
const subtaskName = subtask.task.name || 'Подзадача'
|
||
return (
|
||
<div key={subtask.task.id} className="subtask-item">
|
||
<label className="subtask-checkbox-label">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedSubtasks.has(subtask.task.id)}
|
||
onChange={() => handleSubtaskToggle(subtask.task.id)}
|
||
className="subtask-checkbox"
|
||
/>
|
||
<div className="subtask-content">
|
||
<div className="subtask-name">{subtaskName}</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Разделитель - показываем только если есть прогрессия или подзадачи */}
|
||
{(hasProgression || (subtasks && subtasks.length > 0)) && (
|
||
<div className="task-detail-divider"></div>
|
||
)}
|
||
|
||
{/* Сообщение награды - показываем только если есть прогрессия или подзадачи */}
|
||
{(hasProgression || (subtasks && subtasks.length > 0)) && (
|
||
<div className="telegram-message-preview">
|
||
<div className="telegram-message-label">Сообщение награды:</div>
|
||
<div className="telegram-message-text" dangerouslySetInnerHTML={{
|
||
__html: telegramMessage
|
||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/\n/g, '<br>')
|
||
}} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Кнопки действий */}
|
||
<div className="task-actions-section">
|
||
<button
|
||
onClick={() => handleComplete(false)}
|
||
disabled={isCompleting || !canComplete}
|
||
className="complete-button"
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginRight: '0.5rem' }}>
|
||
<path d="M13.5 4L6 11.5L2.5 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||
</svg>
|
||
{isCompleting ? 'Выполнение...' : 'Выполнить'}
|
||
</button>
|
||
{!isOneTime && (
|
||
<button
|
||
onClick={() => handleComplete(true)}
|
||
disabled={isCompleting || !canComplete}
|
||
className="close-button-outline"
|
||
title="Выполнить и закрыть"
|
||
>
|
||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M3 7L7 11L15 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||
<path d="M3 11L7 15L15 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default TaskDetail
|
||
|