2026-01-10 19:17:03 +03:00
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
2026-01-04 19:37:59 +03:00
|
|
|
|
import { useAuth } from './auth/AuthContext'
|
2026-01-11 15:51:28 +03:00
|
|
|
|
import LoadingError from './LoadingError'
|
|
|
|
|
|
import Toast from './Toast'
|
2026-01-04 19:37:59 +03:00
|
|
|
|
import './TaskDetail.css'
|
|
|
|
|
|
|
|
|
|
|
|
const API_URL = '/api/tasks'
|
|
|
|
|
|
|
2026-01-10 19:17:03 +03:00
|
|
|
|
// Функция для проверки, является ли период нулевым
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 19:27:36 +03:00
|
|
|
|
// Функция для вычисления следующей даты по repetition_date
|
|
|
|
|
|
const calculateNextDateFromRepetitionDate = (repetitionDateStr) => {
|
|
|
|
|
|
if (!repetitionDateStr) return null
|
|
|
|
|
|
|
|
|
|
|
|
const parts = repetitionDateStr.trim().split(/\s+/)
|
|
|
|
|
|
if (parts.length < 2) return null
|
|
|
|
|
|
|
|
|
|
|
|
const value = parts[0]
|
|
|
|
|
|
const unit = parts[1].toLowerCase()
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
now.setHours(0, 0, 0, 0)
|
|
|
|
|
|
|
|
|
|
|
|
switch (unit) {
|
|
|
|
|
|
case 'week': {
|
|
|
|
|
|
// N-й день недели (1=понедельник, 7=воскресенье)
|
|
|
|
|
|
const dayOfWeek = parseInt(value, 10)
|
|
|
|
|
|
if (isNaN(dayOfWeek) || dayOfWeek < 1 || dayOfWeek > 7) return null
|
|
|
|
|
|
// JavaScript: 0=воскресенье, 1=понедельник... 6=суббота
|
|
|
|
|
|
// Наш формат: 1=понедельник... 7=воскресенье
|
|
|
|
|
|
// Конвертируем: наш 1 (Пн) -> JS 1, наш 7 (Вс) -> JS 0
|
|
|
|
|
|
const targetJsDay = dayOfWeek === 7 ? 0 : dayOfWeek
|
|
|
|
|
|
const currentJsDay = now.getDay()
|
|
|
|
|
|
// Вычисляем дни до следующего вхождения (включая сегодня, если ещё не прошло)
|
|
|
|
|
|
let daysUntil = (targetJsDay - currentJsDay + 7) % 7
|
|
|
|
|
|
// Если сегодня тот же день, берём следующую неделю
|
|
|
|
|
|
if (daysUntil === 0) daysUntil = 7
|
|
|
|
|
|
const nextDate = new Date(now)
|
|
|
|
|
|
nextDate.setDate(now.getDate() + daysUntil)
|
|
|
|
|
|
return nextDate
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'month': {
|
|
|
|
|
|
// N-й день месяца
|
|
|
|
|
|
const dayOfMonth = parseInt(value, 10)
|
|
|
|
|
|
if (isNaN(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) return null
|
|
|
|
|
|
|
|
|
|
|
|
// Ищем ближайшую дату с этим днём
|
|
|
|
|
|
let searchDate = new Date(now)
|
|
|
|
|
|
for (let i = 0; i < 12; i++) {
|
|
|
|
|
|
const year = searchDate.getFullYear()
|
|
|
|
|
|
const month = searchDate.getMonth()
|
|
|
|
|
|
const lastDayOfMonth = new Date(year, month + 1, 0).getDate()
|
|
|
|
|
|
const actualDay = Math.min(dayOfMonth, lastDayOfMonth)
|
|
|
|
|
|
const candidateDate = new Date(year, month, actualDay)
|
|
|
|
|
|
|
|
|
|
|
|
if (candidateDate > now) {
|
|
|
|
|
|
return candidateDate
|
|
|
|
|
|
}
|
|
|
|
|
|
// Переходим к следующему месяцу
|
|
|
|
|
|
searchDate = new Date(year, month + 1, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'year': {
|
|
|
|
|
|
// MM-DD формат
|
|
|
|
|
|
const dateParts = value.split('-')
|
|
|
|
|
|
if (dateParts.length !== 2) return null
|
|
|
|
|
|
const monthNum = parseInt(dateParts[0], 10)
|
|
|
|
|
|
const day = parseInt(dateParts[1], 10)
|
|
|
|
|
|
if (isNaN(monthNum) || isNaN(day) || monthNum < 1 || monthNum > 12 || day < 1 || day > 31) return null
|
|
|
|
|
|
|
|
|
|
|
|
let year = now.getFullYear()
|
|
|
|
|
|
let candidateDate = new Date(year, monthNum - 1, day)
|
|
|
|
|
|
|
|
|
|
|
|
if (candidateDate <= now) {
|
|
|
|
|
|
candidateDate = new Date(year + 1, monthNum - 1, day)
|
|
|
|
|
|
}
|
|
|
|
|
|
return candidateDate
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Функция для вычисления следующей даты по repetition_period
|
2026-01-11 15:09:32 +03:00
|
|
|
|
// Поддерживает сокращенные формы единиц времени (например, "mons" для месяцев)
|
2026-01-10 19:27:36 +03:00
|
|
|
|
const calculateNextDateFromRepetitionPeriod = (repetitionPeriodStr) => {
|
|
|
|
|
|
if (!repetitionPeriodStr) return null
|
|
|
|
|
|
|
|
|
|
|
|
const parts = repetitionPeriodStr.trim().split(/\s+/)
|
|
|
|
|
|
if (parts.length < 2) return null
|
|
|
|
|
|
|
|
|
|
|
|
const value = parseInt(parts[0], 10)
|
|
|
|
|
|
if (isNaN(value) || value === 0) return null
|
|
|
|
|
|
|
|
|
|
|
|
const unit = parts[1].toLowerCase()
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
now.setHours(0, 0, 0, 0)
|
|
|
|
|
|
|
|
|
|
|
|
const nextDate = new Date(now)
|
|
|
|
|
|
|
|
|
|
|
|
switch (unit) {
|
|
|
|
|
|
case 'minute':
|
|
|
|
|
|
case 'minutes':
|
2026-01-11 15:09:32 +03:00
|
|
|
|
case 'mins':
|
|
|
|
|
|
case 'min':
|
2026-01-10 19:27:36 +03:00
|
|
|
|
nextDate.setMinutes(nextDate.getMinutes() + value)
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'hour':
|
|
|
|
|
|
case 'hours':
|
2026-01-11 15:09:32 +03:00
|
|
|
|
case 'hrs':
|
|
|
|
|
|
case 'hr':
|
2026-01-10 19:27:36 +03:00
|
|
|
|
nextDate.setHours(nextDate.getHours() + value)
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'day':
|
|
|
|
|
|
case 'days':
|
2026-01-11 15:09:32 +03:00
|
|
|
|
// PostgreSQL может возвращать недели как дни (например, "7 days" вместо "1 week")
|
|
|
|
|
|
// Если количество дней кратно 7, обрабатываем как недели
|
|
|
|
|
|
if (value % 7 === 0 && value >= 7) {
|
|
|
|
|
|
const weeks = value / 7
|
|
|
|
|
|
nextDate.setDate(nextDate.getDate() + weeks * 7)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
nextDate.setDate(nextDate.getDate() + value)
|
|
|
|
|
|
}
|
2026-01-10 19:27:36 +03:00
|
|
|
|
break
|
|
|
|
|
|
case 'week':
|
|
|
|
|
|
case 'weeks':
|
2026-01-11 15:09:32 +03:00
|
|
|
|
case 'wks':
|
|
|
|
|
|
case 'wk':
|
2026-01-10 19:27:36 +03:00
|
|
|
|
nextDate.setDate(nextDate.getDate() + value * 7)
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'month':
|
|
|
|
|
|
case 'months':
|
2026-01-11 15:09:32 +03:00
|
|
|
|
case 'mons':
|
|
|
|
|
|
case 'mon':
|
2026-01-10 19:27:36 +03:00
|
|
|
|
nextDate.setMonth(nextDate.getMonth() + value)
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'year':
|
|
|
|
|
|
case 'years':
|
2026-01-11 15:09:32 +03:00
|
|
|
|
case 'yrs':
|
|
|
|
|
|
case 'yr':
|
2026-01-10 19:27:36 +03:00
|
|
|
|
nextDate.setFullYear(nextDate.getFullYear() + value)
|
|
|
|
|
|
break
|
|
|
|
|
|
default:
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nextDate
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Форматирование даты в YYYY-MM-DD (локальное время, без смещения в UTC)
|
|
|
|
|
|
const formatDateToLocal = (date) => {
|
|
|
|
|
|
const year = date.getFullYear()
|
|
|
|
|
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
|
|
|
|
const day = String(date.getDate()).padStart(2, '0')
|
|
|
|
|
|
return `${year}-${month}-${day}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Форматирование даты для отображения с понятными названиями
|
|
|
|
|
|
const formatDateForDisplay = (dateStr) => {
|
|
|
|
|
|
if (!dateStr) return ''
|
|
|
|
|
|
|
|
|
|
|
|
// Парсим дату из формата YYYY-MM-DD
|
|
|
|
|
|
const dateParts = dateStr.split('-')
|
|
|
|
|
|
if (dateParts.length !== 3) return dateStr
|
|
|
|
|
|
|
|
|
|
|
|
const yearNum = parseInt(dateParts[0], 10)
|
|
|
|
|
|
const monthNum = parseInt(dateParts[1], 10) - 1 // месяцы в JS начинаются с 0
|
|
|
|
|
|
const dayNum = parseInt(dateParts[2], 10)
|
|
|
|
|
|
|
|
|
|
|
|
if (isNaN(yearNum) || isNaN(monthNum) || isNaN(dayNum)) return dateStr
|
|
|
|
|
|
|
|
|
|
|
|
const targetDate = new Date(yearNum, monthNum, dayNum)
|
|
|
|
|
|
targetDate.setHours(0, 0, 0, 0)
|
|
|
|
|
|
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
now.setHours(0, 0, 0, 0)
|
|
|
|
|
|
|
|
|
|
|
|
const diffDays = Math.floor((targetDate - now) / (1000 * 60 * 60 * 24))
|
|
|
|
|
|
|
|
|
|
|
|
// Сегодня
|
|
|
|
|
|
if (diffDays === 0) {
|
|
|
|
|
|
return 'Сегодня'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Завтра
|
|
|
|
|
|
if (diffDays === 1) {
|
|
|
|
|
|
return 'Завтра'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Вчера
|
|
|
|
|
|
if (diffDays === -1) {
|
|
|
|
|
|
return 'Вчера'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Дни недели для ближайших дней из будущего (в пределах 7 дней)
|
|
|
|
|
|
if (diffDays > 0 && diffDays <= 7) {
|
|
|
|
|
|
const dayNames = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']
|
|
|
|
|
|
const dayOfWeek = targetDate.getDay()
|
|
|
|
|
|
return dayNames[dayOfWeek]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const monthNames = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
|
|
|
|
|
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
|
|
|
|
|
|
|
|
|
|
|
|
// Если это число из того же года - только день и месяц
|
|
|
|
|
|
if (targetDate.getFullYear() === now.getFullYear()) {
|
|
|
|
|
|
const displayDay = targetDate.getDate()
|
|
|
|
|
|
const displayMonth = monthNames[targetDate.getMonth()]
|
|
|
|
|
|
return `${displayDay} ${displayMonth}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Для других случаев - полная дата
|
|
|
|
|
|
const displayDay = targetDate.getDate()
|
|
|
|
|
|
const displayMonth = monthNames[targetDate.getMonth()]
|
|
|
|
|
|
const displayYear = targetDate.getFullYear()
|
|
|
|
|
|
return `${displayDay} ${displayMonth} ${displayYear}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 19:17:03 +03:00
|
|
|
|
// Функция для форматирования числа как %.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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 18:58:52 +03:00
|
|
|
|
function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) {
|
2026-01-04 19:37:59 +03:00
|
|
|
|
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)
|
2026-01-11 15:51:28 +03:00
|
|
|
|
const [toastMessage, setToastMessage] = useState(null)
|
2026-01-12 18:58:52 +03:00
|
|
|
|
const [wishlistInfo, setWishlistInfo] = useState(null)
|
2026-01-04 19:37:59 +03:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-01-12 18:58:52 +03:00
|
|
|
|
|
|
|
|
|
|
// Загружаем информацию о связанном желании, если есть
|
|
|
|
|
|
if (data.task.wishlist_id) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const wishlistResponse = await authFetch(`/api/wishlist/${data.task.wishlist_id}`)
|
|
|
|
|
|
if (wishlistResponse.ok) {
|
|
|
|
|
|
const wishlistData = await wishlistResponse.json()
|
|
|
|
|
|
setWishlistInfo({
|
|
|
|
|
|
id: wishlistData.id,
|
|
|
|
|
|
name: wishlistData.name,
|
|
|
|
|
|
unlocked: wishlistData.unlocked || false
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Error loading wishlist info:', err)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setWishlistInfo(null)
|
|
|
|
|
|
}
|
2026-01-04 19:37:59 +03:00
|
|
|
|
} 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
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 19:17:03 +03:00
|
|
|
|
const handleComplete = async (shouldDelete = false) => {
|
2026-01-04 19:37:59 +03:00
|
|
|
|
if (!taskDetail) return
|
|
|
|
|
|
|
2026-01-12 18:58:52 +03:00
|
|
|
|
// Проверяем, что желание разблокировано (если есть связанное желание)
|
|
|
|
|
|
if (wishlistInfo && !wishlistInfo.unlocked) {
|
|
|
|
|
|
setToastMessage({ text: 'Невозможно выполнить задачу: желание не разблокировано', type: 'error' })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 19:17:03 +03:00
|
|
|
|
// Если прогрессия не введена, используем 0 (валидация не требуется)
|
2026-01-04 19:37:59 +03:00
|
|
|
|
|
|
|
|
|
|
setIsCompleting(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = {
|
|
|
|
|
|
children_task_ids: Array.from(selectedSubtasks)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 19:17:03 +03:00
|
|
|
|
// Если есть прогрессия, отправляем значение (или 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
|
2026-01-04 19:37:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 19:17:03 +03:00
|
|
|
|
// Используем единую ручку для выполнения и удаления
|
|
|
|
|
|
const endpoint = shouldDelete
|
|
|
|
|
|
? `${API_URL}/${taskId}/complete-and-delete`
|
|
|
|
|
|
: `${API_URL}/${taskId}/complete`
|
|
|
|
|
|
|
|
|
|
|
|
const response = await authFetch(endpoint, {
|
2026-01-04 19:37:59 +03:00
|
|
|
|
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)
|
2026-01-11 15:51:28 +03:00
|
|
|
|
setToastMessage({ text: err.message || 'Ошибка при выполнении задачи', type: 'error' })
|
2026-01-04 19:37:59 +03:00
|
|
|
|
} finally {
|
|
|
|
|
|
setIsCompleting(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!taskId) return null
|
|
|
|
|
|
|
|
|
|
|
|
const { task, rewards, subtasks } = taskDetail || {}
|
|
|
|
|
|
const hasProgression = task?.progression_base != null
|
2026-01-12 18:58:52 +03:00
|
|
|
|
// Кнопка активна только если желание разблокировано (или задачи нет связанного желания)
|
|
|
|
|
|
const canComplete = !wishlistInfo || wishlistInfo.unlocked
|
2026-01-10 19:17:03 +03:00
|
|
|
|
|
|
|
|
|
|
// Определяем, является ли задача одноразовой
|
|
|
|
|
|
// Одноразовая задача: когда оба поля 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)
|
|
|
|
|
|
|
2026-01-10 19:27:36 +03:00
|
|
|
|
// Вычисляем следующую дату для неодноразовых задач
|
|
|
|
|
|
const nextTaskDate = useMemo(() => {
|
|
|
|
|
|
if (!task || isOneTime) return null
|
|
|
|
|
|
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
now.setHours(0, 0, 0, 0)
|
|
|
|
|
|
|
|
|
|
|
|
let nextDate = null
|
|
|
|
|
|
|
|
|
|
|
|
if (task.repetition_date) {
|
|
|
|
|
|
// Для задач с repetition_date - вычисляем следующую подходящую дату
|
|
|
|
|
|
nextDate = calculateNextDateFromRepetitionDate(task.repetition_date)
|
|
|
|
|
|
} else if (task.repetition_period && !isZeroPeriod(task.repetition_period)) {
|
|
|
|
|
|
// Для задач с repetition_period (не нулевым) - вычисляем следующую дату
|
|
|
|
|
|
nextDate = calculateNextDateFromRepetitionPeriod(task.repetition_period)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!nextDate) return null
|
|
|
|
|
|
|
|
|
|
|
|
nextDate.setHours(0, 0, 0, 0)
|
|
|
|
|
|
return formatDateForDisplay(formatDateToLocal(nextDate))
|
|
|
|
|
|
}, [task, isOneTime])
|
|
|
|
|
|
|
2026-01-10 19:17:03 +03:00
|
|
|
|
// Формируем сообщение для Telegram в реальном времени
|
|
|
|
|
|
const telegramMessage = useMemo(() => {
|
|
|
|
|
|
if (!taskDetail) return ''
|
|
|
|
|
|
return formatTelegramMessage(task, rewards || [], subtasks || [], selectedSubtasks, progressionValue)
|
|
|
|
|
|
}, [taskDetail, task, rewards, subtasks, selectedSubtasks, progressionValue])
|
2026-01-04 19:37:59 +03:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-11 15:51:28 +03:00
|
|
|
|
{error && !loading && (
|
|
|
|
|
|
<LoadingError onRetry={fetchTaskDetail} />
|
2026-01-04 19:37:59 +03:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && !error && taskDetail && (
|
|
|
|
|
|
<>
|
2026-01-12 18:58:52 +03:00
|
|
|
|
{/* Информация о связанном желании */}
|
|
|
|
|
|
{task.wishlist_id && wishlistInfo && (
|
|
|
|
|
|
<div className="task-wishlist-link">
|
|
|
|
|
|
<div className="task-wishlist-link-info">
|
|
|
|
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
|
|
|
|
<polyline points="20 12 20 22 4 22 4 12"></polyline>
|
|
|
|
|
|
<rect x="2" y="7" width="20" height="5"></rect>
|
|
|
|
|
|
<line x1="12" y1="22" x2="12" y2="7"></line>
|
|
|
|
|
|
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
|
|
|
|
|
|
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<span className="task-wishlist-link-label">Связано с желанием:</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (onClose) onClose()
|
|
|
|
|
|
if (onNavigate && wishlistInfo) {
|
|
|
|
|
|
onNavigate('wishlist-detail', { wishlistId: wishlistInfo.id })
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="task-wishlist-link-button"
|
|
|
|
|
|
>
|
|
|
|
|
|
{wishlistInfo.name}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-10 19:17:03 +03:00
|
|
|
|
{/* Поле ввода прогрессии */}
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Список подзадач */}
|
2026-01-04 19:37:59 +03:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-10 19:17:03 +03:00
|
|
|
|
{/* Разделитель - показываем только если есть прогрессия или подзадачи */}
|
|
|
|
|
|
{(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">
|
2026-01-10 19:27:36 +03:00
|
|
|
|
<div className="task-actions-buttons">
|
2026-01-04 19:37:59 +03:00
|
|
|
|
<button
|
2026-01-10 19:27:36 +03:00
|
|
|
|
onClick={() => handleComplete(false)}
|
2026-01-04 19:37:59 +03:00
|
|
|
|
disabled={isCompleting || !canComplete}
|
2026-01-10 19:27:36 +03:00
|
|
|
|
className="complete-button"
|
2026-01-12 18:58:52 +03:00
|
|
|
|
title={!canComplete && wishlistInfo ? 'Желание не разблокировано' : ''}
|
2026-01-04 19:37:59 +03:00
|
|
|
|
>
|
2026-01-12 18:58:52 +03:00
|
|
|
|
{!canComplete && wishlistInfo ? (
|
|
|
|
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '0.5rem' }}>
|
|
|
|
|
|
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-01-10 19:27:36 +03:00
|
|
|
|
{isCompleting ? 'Выполнение...' : 'Выполнить'}
|
2026-01-04 19:37:59 +03:00
|
|
|
|
</button>
|
2026-01-12 18:58:52 +03:00
|
|
|
|
{!isOneTime && canComplete && (
|
2026-01-10 19:27:36 +03:00
|
|
|
|
<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>
|
|
|
|
|
|
{!isOneTime && nextTaskDate && (
|
|
|
|
|
|
<div className="next-task-date-info">
|
|
|
|
|
|
Следующая: {nextTaskDate}
|
|
|
|
|
|
</div>
|
2026-01-04 19:37:59 +03:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-11 15:51:28 +03:00
|
|
|
|
{toastMessage && (
|
|
|
|
|
|
<Toast
|
|
|
|
|
|
message={toastMessage.text}
|
|
|
|
|
|
type={toastMessage.type}
|
|
|
|
|
|
onClose={() => setToastMessage(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-01-04 19:37:59 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default TaskDetail
|
|
|
|
|
|
|