v3.6.0: Улучшено модальное окно переноса задачи - нередактируемое поле с понятным форматированием даты
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 55s

This commit is contained in:
poignatov
2026-01-10 19:17:03 +03:00
parent 3d3fa13f41
commit cc7c6a905e
7 changed files with 767 additions and 92 deletions

View File

@@ -1,9 +1,168 @@
import React, { useState, useEffect, useCallback } from 'react'
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)
@@ -56,14 +215,10 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
})
}
const handleComplete = async () => {
const handleComplete = async (shouldDelete = false) => {
if (!taskDetail) return
// Валидация: если progression_base != null, то value обязателен
if (taskDetail.task.progression_base != null && !progressionValue.trim()) {
alert('Поле "Значение" обязательно для задач с прогрессией')
return
}
// Если прогрессия не введена, используем 0 (валидация не требуется)
setIsCompleting(true)
try {
@@ -71,14 +226,25 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
children_task_ids: Array.from(selectedSubtasks)
}
if (taskDetail.task.progression_base != null && progressionValue.trim()) {
payload.value = parseFloat(progressionValue)
if (isNaN(payload.value)) {
throw new Error('Неверное значение')
// Если есть прогрессия, отправляем значение (или 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 response = await authFetch(`${API_URL}/${taskId}/complete`, {
// Используем единую ручку для выполнения и удаления
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',
@@ -115,7 +281,23 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
const { task, rewards, subtasks } = taskDetail || {}
const hasProgression = task?.progression_base != null
const canComplete = !hasProgression || (hasProgression && progressionValue.trim())
// Кнопка всегда активна (если прогрессия не введена, используем 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}>
@@ -140,6 +322,22 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
{!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) => {
@@ -163,34 +361,46 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
</div>
)}
<div className="task-complete-section">
{hasProgression ? (
<div className="progression-input-group">
<input
type="number"
step="any"
value={progressionValue}
onChange={(e) => setProgressionValue(e.target.value)}
placeholder={`Значение (~${task.progression_base})`}
className="progression-input"
/>
{progressionValue.trim() && (
<button
onClick={handleComplete}
disabled={isCompleting}
className="complete-button"
>
</button>
)}
</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}
onClick={() => handleComplete(true)}
disabled={isCompleting || !canComplete}
className="complete-button full-width"
className="close-button-outline"
title="Выполнить и закрыть"
>
{isCompleting ? 'Выполнение...' : 'Выполнить'}
<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>