Files
play-life/play-life-web/src/components/TaskDetail.jsx

934 lines
36 KiB
React
Raw Normal View History

import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import Toast from './Toast'
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
}
// Функция для вычисления следующей даты по 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
// Поддерживает сокращенные формы единиц времени (например, "mons" для месяцев)
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':
case 'mins':
case 'min':
nextDate.setMinutes(nextDate.getMinutes() + value)
break
case 'hour':
case 'hours':
case 'hrs':
case 'hr':
nextDate.setHours(nextDate.getHours() + value)
break
case 'day':
case 'days':
// 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)
}
break
case 'week':
case 'weeks':
case 'wks':
case 'wk':
nextDate.setDate(nextDate.getDate() + value * 7)
break
case 'month':
case 'months':
case 'mons':
case 'mon':
nextDate.setMonth(nextDate.getMonth() + value)
break
case 'year':
case 'years':
case 'yrs':
case 'yr':
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}`
}
// Функция для форматирования числа как %.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, onNavigate }) {
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 [toastMessage, setToastMessage] = useState(null)
const [wishlistInfo, setWishlistInfo] = useState(null)
const [isSaving, setIsSaving] = useState(false)
const [completeAtEndOfDay, setCompleteAtEndOfDay] = 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)
// Используем информацию о wishlist из ответа API
if (data.wishlist_info) {
setWishlistInfo({
id: data.wishlist_info.id,
name: data.wishlist_info.name,
unlocked: data.wishlist_info.unlocked || false
})
} else {
setWishlistInfo(null)
}
// Предзаполнение данных из драфта
if (data.draft_progression_value != null) {
setProgressionValue(data.draft_progression_value.toString())
}
if (data.draft_subtasks && data.draft_subtasks.length > 0) {
// Создаем Set из ID подзадач из драфта
const draftSubtaskIDs = new Set(data.draft_subtasks.map(ds => ds.subtask_id))
// Фильтруем только те подзадачи, которые существуют в текущих подзадачах задачи
const validSubtaskIDs = new Set()
if (data.subtasks) {
data.subtasks.forEach(subtask => {
if (draftSubtaskIDs.has(subtask.task.id)) {
validSubtaskIDs.add(subtask.task.id)
}
})
}
setSelectedSubtasks(validSubtaskIDs)
}
// Значение чекбокса будет установлено в useEffect при изменении taskDetail
} 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('')
setCompleteAtEndOfDay(false)
}
}, [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 handleSave = async () => {
if (!taskDetail) return
// Если чекбокс включен - выполняем в конце дня, иначе сохраняем без автовыполнения
const autoComplete = completeAtEndOfDay
setIsSaving(true)
try {
const payload = {
auto_complete: autoComplete,
children_task_ids: Array.from(selectedSubtasks)
}
// Если есть прогрессия, отправляем значение (или null, если не введено)
if (taskDetail.task.progression_base != null) {
if (progressionValue.trim()) {
const parsedValue = parseFloat(progressionValue)
if (isNaN(parsedValue)) {
throw new Error('Неверное значение')
}
payload.progression_value = parsedValue
} else {
// Если прогрессия не введена - отправляем null
payload.progression_value = null
}
} else {
// Если нет progression_base, но пользователь ввел значение - отправляем его
if (progressionValue.trim()) {
const parsedValue = parseFloat(progressionValue)
if (!isNaN(parsedValue)) {
payload.progression_value = parsedValue
}
}
}
const endpoint = autoComplete
? `${API_URL}/${taskId}/complete-at-end-of-day`
: `${API_URL}/${taskId}/draft`
const response = await authFetch(endpoint, {
method: autoComplete ? 'POST' : 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || 'Ошибка при сохранении драфта')
}
setToastMessage({
text: autoComplete
? 'Задача будет выполнена в конце дня'
: 'Драфт сохранен',
type: 'success'
})
// Обновляем данные задачи, чтобы получить актуальное значение auto_complete
await fetchTaskDetail()
// Обновляем данные задачи в списке
if (onRefresh) {
onRefresh()
}
// Закрываем модальное окно после успешного сохранения
if (onClose) {
onClose()
}
} catch (err) {
console.error('Error saving draft:', err)
setToastMessage({ text: err.message || 'Ошибка при сохранении драфта', type: 'error' })
} finally {
setIsSaving(false)
}
}
const handleComplete = async () => {
if (!taskDetail) return
// Проверяем, что желание разблокировано (если есть связанное желание)
if (wishlistInfo && !wishlistInfo.unlocked) {
setToastMessage({ text: 'Невозможно выполнить задачу: желание не разблокировано', type: 'error' })
return
}
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 = `${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)
setToastMessage({ text: err.message || 'Ошибка при выполнении задачи', type: 'error' })
} finally {
setIsCompleting(false)
}
}
const handleCompleteFinally = async () => {
if (!taskDetail) return
// Проверяем, что желание разблокировано (если есть связанное желание)
if (wishlistInfo && !wishlistInfo.unlocked) {
setToastMessage({ text: 'Невозможно выполнить задачу: желание не разблокировано', type: 'error' })
return
}
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 = `${API_URL}/${taskId}/complete-and-delete`
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)
setToastMessage({ text: err.message || 'Ошибка при выполнении задачи', type: 'error' })
} finally {
setIsCompleting(false)
}
}
if (!taskId) return null
const { task, rewards, subtasks } = taskDetail || {}
const hasProgression = task?.progression_base != null
// Кнопка активна только если желание разблокировано (или задачи нет связанного желания)
const canComplete = !wishlistInfo || wishlistInfo.unlocked
const hasProgressionOrSubtasks = hasProgression || (subtasks && subtasks.length > 0)
// Определяем, является ли задача одноразовой
// Одноразовая задача: когда оба поля 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)
// Вычисляем следующую дату для неодноразовых задач
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])
// Формируем сообщение для Telegram в реальном времени
const telegramMessage = useMemo(() => {
if (!taskDetail) return ''
return formatTelegramMessage(task, rewards || [], subtasks || [], selectedSubtasks, progressionValue)
}, [taskDetail, task, rewards, subtasks, selectedSubtasks, progressionValue])
// Обновляем значение чекбокса при изменении taskDetail
useEffect(() => {
if (taskDetail && taskDetail.task) {
const autoCompleteValue = Boolean(taskDetail.task.auto_complete)
console.log('useEffect: Updating completeAtEndOfDay from taskDetail:', autoCompleteValue, 'task.auto_complete:', taskDetail.task.auto_complete)
setCompleteAtEndOfDay(autoCompleteValue)
} else {
setCompleteAtEndOfDay(false)
}
}, [taskDetail])
const modalContent = (
<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"
onClick={taskDetail ? () => {
// Закрываем модальное окно БЕЗ history.back() (skipHistoryBack = true)
// handleTabChange заменит запись модального окна через replaceState
onClose?.(true)
onNavigate?.('task-form', { taskId: taskId })
} : undefined}
style={{ cursor: taskDetail ? 'pointer' : 'default' }}
>
{loading ? 'Загрузка...' : error ? 'Ошибка' : taskDetail ? (
<>
{task.name}
<svg
className="task-detail-edit-icon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
</>
) : 'Задача'}
</h2>
<button onClick={onClose} className="task-detail-close-button">
</button>
</div>
<div className="task-detail-modal-content">
{loading && (
<div className="loading">Загрузка...</div>
)}
{error && !loading && (
<LoadingError onRetry={fetchTaskDetail} />
)}
{!loading && !error && taskDetail && (
<>
{/* Информация о связанном желании */}
{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>
)}
{/* Поле ввода прогрессии */}
{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>
)}
{/* Разделитель - показываем только если есть контент перед ним */}
{(task.wishlist_id || hasProgression || (subtasks && subtasks.length > 0)) && (
<div className="task-detail-divider"></div>
)}
{/* Сообщение награды */}
<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">
{/* Чекбокс над кнопками */}
<div className="complete-at-end-of-day-checkbox">
<label className="checkbox-label">
<input
type="checkbox"
checked={completeAtEndOfDay}
onChange={(e) => {
console.log('Checkbox changed to:', e.target.checked)
setCompleteAtEndOfDay(e.target.checked)
}}
disabled={isSaving || !canComplete}
className="checkbox-input"
/>
<span>Выполнить в конце дня</span>
</label>
</div>
<div className="task-actions-buttons">
{/* Левая часть: кнопка "Выполнить" */}
<div className="task-action-left">
<button
onClick={handleComplete}
disabled={isCompleting || !canComplete}
className="action-button action-button-check"
title={!canComplete && wishlistInfo ? 'Желание не разблокировано' : 'Выполнить'}
>
Выполнить
</button>
</div>
{/* Правая часть: кнопка "Сохранить" */}
<div className="task-action-complete-buttons">
<button
onClick={handleSave}
disabled={isSaving || !canComplete}
className="action-button action-button-save"
title={!canComplete && wishlistInfo ? 'Желание не разблокировано' : ''}
>
{isSaving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
{/* Дата слева */}
{!isOneTime && nextTaskDate && (
<div className="next-task-date-info">
Следующая: <span className="next-task-date-bold">{nextTaskDate}</span>
</div>
)}
</div>
</>
)}
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
</div>
)
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent
}
export default TaskDetail