Files
play-life/play-life-web/src/components/TaskList.jsx
poignatov 09ab87b6dd
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m33s
4.18.0: Улучшен календарь выбора даты
2026-02-04 21:13:29 +03:00

1070 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useMemo, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext'
import TaskDetail from './TaskDetail'
import LoadingError from './LoadingError'
import Toast from './Toast'
import { DayPicker } from 'react-day-picker'
import { ru } from 'react-day-picker/locale'
import 'react-day-picker/style.css'
import './TaskList.css'
const API_URL = '/api/tasks'
function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry, onRefresh }) {
const { authFetch } = useAuth()
// Инициализируем tasks из data, если data есть, иначе пустой массив
const [tasks, setTasks] = useState(() => data && Array.isArray(data) ? data : [])
const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null)
const [isCompleting, setIsCompleting] = useState(false)
const [expandedCompleted, setExpandedCompleted] = useState({})
const [selectedTaskForPostpone, setSelectedTaskForPostpone] = useState(null)
const [postponeDate, setPostponeDate] = useState('')
const [isPostponing, setIsPostponing] = useState(false)
const [toast, setToast] = useState(null)
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
if (data) {
setTasks(data)
}
}, [data])
// Загрузка данных управляется из App.jsx через loadTabData
// TaskList не инициирует загрузку самостоятельно
const handleTaskClick = (task) => {
setSelectedTaskForDetail(task.id)
}
const handleCheckmarkClick = async (task, e) => {
e.stopPropagation()
// Для задач-тестов запускаем тест вместо открытия модального окна
const isTest = task.config_id != null
if (isTest) {
if (task.config_id) {
try {
// Загружаем детальную информацию о задаче, чтобы получить maxCards
const response = await authFetch(`${API_URL}/${task.id}`)
if (!response.ok) {
throw new Error('Ошибка при загрузке деталей задачи')
}
const taskDetail = await response.json()
// Переходим к тесту с maxCards
onNavigate?.('test', {
configId: task.config_id,
taskId: task.id,
maxCards: taskDetail.max_cards
})
} catch (err) {
console.error('Failed to load task details:', err)
// В случае ошибки всё равно переходим к тесту, но без maxCards
onNavigate?.('test', { configId: task.config_id, taskId: task.id })
}
}
return
}
// Для обычных задач открываем диалог подтверждения
setSelectedTaskForDetail(task.id)
}
const handleCloseDetail = () => {
setSelectedTaskForDetail(null)
}
// Добавляем запись в историю при открытии модального окна и обрабатываем "назад"
const historyPushedRef = useRef(false)
const selectedTaskForDetailRef = useRef(selectedTaskForDetail)
// Обновляем ref при изменении значения
useEffect(() => {
selectedTaskForDetailRef.current = selectedTaskForDetail
}, [selectedTaskForDetail])
useEffect(() => {
if (selectedTaskForDetail && !historyPushedRef.current) {
// Добавляем запись в историю при открытии модального окна
window.history.pushState({ modalOpen: true, type: 'task-detail' }, '', window.location.href)
historyPushedRef.current = true
} else if (!selectedTaskForDetail) {
historyPushedRef.current = false
}
if (!selectedTaskForDetail) return
const handlePopState = (event) => {
// Проверяем наличие модального окна в DOM
const taskDetailModal = document.querySelector('.task-detail-modal-overlay')
// Используем ref для получения актуального состояния
const currentTaskDetail = selectedTaskForDetailRef.current
// Проверяем, открыто ли модальное окно (по состоянию или в DOM)
if (currentTaskDetail || taskDetailModal) {
// Закрываем модальное окно
setSelectedTaskForDetail(null)
historyPushedRef.current = false
// Предотвращаем дальнейшую обработку в App.jsx
// Следующее нажатие "назад" обработается App.jsx нормально
return
}
}
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
}
}, [selectedTaskForDetail])
// Функция для вычисления следующей даты по 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
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}`
}
const handlePostponeClick = (task, e) => {
e.stopPropagation()
setSelectedTaskForPostpone(task)
// Устанавливаем дату по умолчанию
let defaultDate
const now = new Date()
now.setHours(0, 0, 0, 0)
if (task.repetition_date) {
// Для задач с repetition_date - вычисляем следующую подходящую дату
const nextDate = calculateNextDateFromRepetitionDate(task.repetition_date)
if (nextDate) {
defaultDate = nextDate
}
} else if (task.repetition_period && !isZeroPeriod(task.repetition_period)) {
// Для задач с repetition_period (не нулевым) - вычисляем следующую дату
const nextDate = calculateNextDateFromRepetitionPeriod(task.repetition_period)
if (nextDate) {
defaultDate = nextDate
}
}
if (!defaultDate) {
// Без repetition_date/repetition_period или если не удалось вычислить - завтра
defaultDate = new Date(now)
defaultDate.setDate(defaultDate.getDate() + 1)
}
defaultDate.setHours(0, 0, 0, 0)
setPostponeDate(formatDateToLocal(defaultDate))
}
const handlePostponeSubmit = async () => {
if (!selectedTaskForPostpone || !postponeDate) return
await handlePostponeSubmitWithDate(postponeDate)
}
const handlePostponeClose = () => {
setSelectedTaskForPostpone(null)
setPostponeDate('')
}
const handleDateSelect = (date) => {
if (!date) return
// Преобразуем дату в формат YYYY-MM-DD
const formattedDate = formatDateToLocal(date)
setPostponeDate(formattedDate)
// Применяем дату и закрываем модальное окно
if (selectedTaskForPostpone) {
handlePostponeSubmitWithDate(formattedDate)
}
}
const handleDayClick = (day, modifiers) => {
// Обрабатываем клик даже если дата уже выбрана
if (day && !modifiers.disabled) {
handleDateSelect(day)
}
}
const handleTodayClick = () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
setPostponeDate(formatDateToLocal(today))
// Применяем дату сразу
if (selectedTaskForPostpone) {
handlePostponeSubmitWithDate(formatDateToLocal(today))
}
}
const handleTomorrowClick = () => {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setHours(0, 0, 0, 0)
setPostponeDate(formatDateToLocal(tomorrow))
// Применяем дату сразу
if (selectedTaskForPostpone) {
handlePostponeSubmitWithDate(formatDateToLocal(tomorrow))
}
}
const handlePostponeSubmitWithDate = async (dateToUse) => {
if (!selectedTaskForPostpone || !dateToUse) return
setIsPostponing(true)
try {
// Преобразуем дату в ISO формат с временем
const dateObj = new Date(dateToUse)
dateObj.setHours(0, 0, 0, 0)
const isoDate = dateObj.toISOString()
const response = await authFetch(`${API_URL}/${selectedTaskForPostpone.id}/postpone`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ next_show_at: isoDate }),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || 'Ошибка при переносе задачи')
}
// Обновляем список
if (onRefresh) {
onRefresh()
}
// Закрываем модальное окно
setSelectedTaskForPostpone(null)
setPostponeDate('')
} catch (err) {
console.error('Error postponing task:', err)
setToast({ message: err.message || 'Ошибка при переносе задачи', type: 'error' })
} finally {
setIsPostponing(false)
}
}
const toggleCompletedExpanded = (projectName) => {
setExpandedCompleted(prev => ({
...prev,
[projectName]: !prev[projectName]
}))
}
// Получаем все проекты из задачи (теперь они приходят в task.project_names)
const getTaskProjects = (task) => {
if (task.project_names && Array.isArray(task.project_names)) {
return task.project_names
}
return []
}
// Функция для проверки, является ли период нулевым
const isZeroPeriod = (intervalStr) => {
if (!intervalStr) return false
const trimmed = intervalStr.trim()
// Проверяем формат времени "00:00:00" или "0:00:00"
if (/^\d{1,2}:\d{2}:\d{2}/.test(trimmed)) {
const timeParts = trimmed.split(':')
if (timeParts.length >= 3) {
const hours = parseInt(timeParts[0], 10)
const minutes = parseInt(timeParts[1], 10)
const seconds = parseInt(timeParts[2], 10)
return !isNaN(hours) && !isNaN(minutes) && !isNaN(seconds) &&
hours === 0 && minutes === 0 && seconds === 0
}
}
// PostgreSQL может возвращать "0 day", "0 days", "0", и т.д.
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]
// Проверяем, является ли значение "0" (для формата "0 week", "0 month", "0 year")
const numValue = parseInt(value, 10)
return !isNaN(numValue) && numValue === 0
}
// Группируем задачи по проектам
const groupedTasks = useMemo(() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
// Фильтруем задачи по поисковому запросу
const filteredTasks = searchQuery.trim()
? tasks.filter(task =>
task.name.toLowerCase().includes(searchQuery.toLowerCase())
)
: tasks
const groups = {}
filteredTasks.forEach(task => {
const projects = getTaskProjects(task)
// Если у задачи нет проектов, добавляем в группу "Без проекта"
if (projects.length === 0) {
projects.push('Без проекта')
}
// Определяем, в какую группу попадает задача
let isCompleted = false
// Сначала проверяем, является ли задача бесконечной
// Бесконечная задача: repetition_period == 0 И (repetition_date == 0 ИЛИ отсутствует)
// Для обратной совместимости: если repetition_period = 0, считаем бесконечной
const hasZeroPeriod = task.repetition_period && isZeroPeriod(task.repetition_period)
const hasZeroDate = task.repetition_date && isZeroDate(task.repetition_date)
// Идеально: оба поля = 0, но для старых задач может быть только repetition_period = 0
const isInfinite = (hasZeroPeriod && hasZeroDate) || (hasZeroPeriod && !task.repetition_date)
// Бесконечные задачи всегда идут в completed, независимо от next_show_at
if (isInfinite) {
isCompleted = true
} else if (task.next_show_at) {
// Для обычных задач используем next_show_at для группировки
const nextShowDate = new Date(task.next_show_at)
nextShowDate.setHours(0, 0, 0, 0)
isCompleted = nextShowDate.getTime() > today.getTime()
} else {
isCompleted = false
}
projects.forEach(projectName => {
if (!groups[projectName]) {
groups[projectName] = {
notCompleted: [],
completed: []
}
}
if (isCompleted) {
groups[projectName].completed.push(task)
} else {
// Бесконечные задачи теперь идут в обычный список
groups[projectName].notCompleted.push(task)
}
})
})
// Сортируем задачи внутри каждой группы проекта
Object.keys(groups).forEach(projectName => {
const group = groups[projectName]
// Сортируем невыполненные задачи: по completed DESC (больше завершений выше), затем по id ASC (раньше добавленные выше)
group.notCompleted.sort((a, b) => {
if (b.completed !== a.completed) {
return b.completed - a.completed // DESC
}
return a.id - b.id // ASC
})
// Сортируем выполненные задачи: бесконечные первыми, затем по next_show_at ASC (ранние в начале), NULL в начале
group.completed.sort((a, b) => {
// Проверяем, является ли задача бесконечной
const hasZeroPeriodA = a.repetition_period && isZeroPeriod(a.repetition_period)
const hasZeroDateA = a.repetition_date && isZeroDate(a.repetition_date)
const isInfiniteA = (hasZeroPeriodA && hasZeroDateA) || (hasZeroPeriodA && !a.repetition_date)
const hasZeroPeriodB = b.repetition_period && isZeroPeriod(b.repetition_period)
const hasZeroDateB = b.repetition_date && isZeroDate(b.repetition_date)
const isInfiniteB = (hasZeroPeriodB && hasZeroDateB) || (hasZeroPeriodB && !b.repetition_date)
// Бесконечные задачи идут первыми
if (isInfiniteA && !isInfiniteB) return -1
if (!isInfiniteA && isInfiniteB) return 1
if (isInfiniteA && isInfiniteB) return 0
// Для остальных: NULL значения идут первыми
if (!a.next_show_at && !b.next_show_at) return 0
if (!a.next_show_at) return -1
if (!b.next_show_at) return 1
// Сравниваем даты
const dateA = new Date(a.next_show_at).getTime()
const dateB = new Date(b.next_show_at).getTime()
return dateA - dateB // ASC
})
})
return groups
}, [tasks, searchQuery])
// Сортируем проекты: сначала с невыполненными задачами, потом без них
// Группа "Без проекта" всегда последняя в своей категории
const projectNames = useMemo(() => {
const sorted = Object.keys(groupedTasks).sort((a, b) => {
const groupA = groupedTasks[a]
const groupB = groupedTasks[b]
const hasNotCompletedA = groupA.notCompleted.length > 0
const hasNotCompletedB = groupB.notCompleted.length > 0
// Если у одной группы есть невыполненные, а у другой нет - сортируем по этому признаку
if (hasNotCompletedA && !hasNotCompletedB) return -1
if (!hasNotCompletedA && hasNotCompletedB) return 1
// Если обе группы в одной категории
const isNoProjectA = a === 'Без проекта'
const isNoProjectB = b === 'Без проекта'
// "Без проекта" всегда последняя в своей категории
if (isNoProjectA && !isNoProjectB) return 1
if (!isNoProjectA && isNoProjectB) return -1
// Остальные группы сортируем по алфавиту
return a.localeCompare(b)
})
return sorted
}, [groupedTasks])
const renderTaskItem = (task, isCompleted = false) => {
const hasProgression = task.has_progression || task.progression_base != null
const hasSubtasks = task.subtasks_count > 0
const isTest = task.config_id != null
const showDetailOnCheckmark = !isTest
const isWishlist = task.wishlist_id != null
// Проверяем бесконечную задачу: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует)
// Для обратной совместимости: если repetition_period = 0, считаем бесконечной
const hasZeroPeriod = task.repetition_period && isZeroPeriod(task.repetition_period)
const hasZeroDate = task.repetition_date && isZeroDate(task.repetition_date)
// Бесконечная задача: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует)
// Не проверяем next_show_at, так как для бесконечных задач он может быть установлен при выполнении
const isInfinite = (hasZeroPeriod && hasZeroDate) || (hasZeroPeriod && !task.repetition_date)
// Одноразовая задача: когда оба поля null/undefined
const isOneTime = (task.repetition_period == null || task.repetition_period === undefined) &&
(task.repetition_date == null || task.repetition_date === undefined)
return (
<div
key={task.id}
className="task-item"
onClick={() => handleTaskClick(task)}
>
<div className="task-item-content">
<div
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''} ${task.auto_complete ? 'task-checkmark-auto-complete' : ''}`}
onClick={(e) => handleCheckmarkClick(task, e)}
title={isTest ? 'Запустить тест' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу')}
>
{isTest ? (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
) : isWishlist ? (
<>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 12v10M4 12v10M20 22H4"></path>
<path d="M22 7H2M22 7v5M2 7v5"></path>
<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>
{task.auto_complete && (
<svg
className="task-checkmark-wishlist-lightning-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
title="Задача-желание"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
</svg>
)}
</>
) : (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
</svg>
)}
{task.auto_complete && !isTest && !isWishlist && (
<svg
className="task-checkmark-auto-complete-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
title="Автовыполнение в конце дня"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
</svg>
)}
</div>
<div className="task-name-container">
<div className="task-name-wrapper">
<div className="task-name">
{task.name}
{hasSubtasks && (
<span className="task-subtasks-count">(+{task.subtasks_count})</span>
)}
<span className="task-badge-bar">
{!isOneTime && !isInfinite && !isWishlist && (
<svg
className="task-recurring-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
title="Повторяющаяся задача"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M3 21v-5h5"/>
</svg>
)}
{isInfinite && (
<svg
className="task-infinite-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
title="Бесконечная задача"
>
<path d="M12 12c0-2.5-1.5-4.5-3.5-4.5S5 9.5 5 12s1.5 4.5 3.5 4.5S12 14.5 12 12z"/>
<path d="M12 12c0 2.5 1.5 4.5 3.5 4.5S19 14.5 19 12s-1.5-4.5-3.5-4.5S12 9.5 12 12z"/>
</svg>
)}
{hasProgression && (
<svg
className="task-progression-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
title="Задача с прогрессией"
>
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline>
<polyline points="17 6 23 6 23 12"></polyline>
</svg>
)}
</span>
</div>
{/* Показываем дату только для выполненных задач */}
{isCompleted && task.next_show_at && (() => {
const showDate = new Date(task.next_show_at)
// Нормализуем дату: устанавливаем время в 00:00:00 в локальном времени
const showDateNormalized = new Date(showDate.getFullYear(), showDate.getMonth(), showDate.getDate())
const today = new Date()
const todayNormalized = new Date(today.getFullYear(), today.getMonth(), today.getDate())
const tomorrowNormalized = new Date(todayNormalized)
tomorrowNormalized.setDate(tomorrowNormalized.getDate() + 1)
// Не показываем текст если дата равна сегодня
if (showDateNormalized.getTime() === todayNormalized.getTime()) {
return null
}
let dateText
if (showDateNormalized.getTime() === tomorrowNormalized.getTime()) {
dateText = 'Завтра'
} else {
dateText = showDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
}
return (
<div className="task-next-show-date">
{dateText}
</div>
)
})()}
</div>
</div>
<div className="task-actions">
<button
className="task-postpone-button"
onClick={(e) => handlePostponeClick(task, e)}
title="Перенести задачу"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="8" stroke="currentColor" strokeWidth="1.5" fill="none"/>
<path d="M10 5V10L13 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" fill="none"/>
</svg>
</button>
</div>
</div>
</div>
)
}
// Показываем загрузку только если данных нет и это не фоновая загрузка
// Проверяем наличие данных более надежно: либо в data, либо в tasks
// Важно: проверяем оба источника данных, так как они могут обновляться асинхронно
const hasDataInProps = data && Array.isArray(data) && data.length > 0
const hasDataInState = tasks && Array.isArray(tasks) && tasks.length > 0
const hasData = hasDataInProps || hasDataInState
// Показываем ошибку загрузки, если есть ошибка и нет данных
if (error && !hasData && !loading) {
return (
<div className="task-list">
<LoadingError onRetry={onRetry} />
</div>
)
}
// Показываем загрузку только если:
// 1. Идет загрузка (loading = true)
// 2. Это не фоновая загрузка (backgroundLoading = false)
// 3. Данных нет (hasData = false)
// Это предотвращает показ загрузки при переключении табов, когда данные уже есть
if (loading && !backgroundLoading && !hasData) {
return (
<div className="task-list">
<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>
</div>
)
}
return (
<div className="task-list">
{toast && (
<Toast
message={toast.message}
type={toast.type || 'success'}
onClose={() => setToast(null)}
/>
)}
{/* Поле поиска */}
<div className="task-search-container">
<svg
className="task-search-icon"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
<input
type="text"
className="task-search-input"
placeholder="Поиск задач..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
className="task-search-clear"
onClick={() => setSearchQuery('')}
title="Очистить поиск"
>
</button>
)}
</div>
{projectNames.length === 0 && !loading && tasks.length === 0 && (
<div className="empty-state">
<p>Задач пока нет. Добавьте задачу через кнопку "Добавить".</p>
</div>
)}
{projectNames.map(projectName => {
const group = groupedTasks[projectName]
const hasCompleted = group.completed.length > 0
const hasNotCompleted = group.notCompleted.length > 0
const isCompletedExpanded = expandedCompleted[projectName]
return (
<div key={projectName} className={`project-group ${!hasNotCompleted ? 'project-group-no-tasks' : ''}`}>
<div
className={`project-group-header ${hasCompleted ? 'project-group-header-clickable' : ''}`}
onClick={hasCompleted ? () => toggleCompletedExpanded(projectName) : undefined}
title={hasCompleted ? (isCompletedExpanded ? 'Скрыть выполненные' : 'Показать выполненные') : undefined}
>
<h3 className={`project-group-title ${!hasNotCompleted ? 'project-group-title-empty' : ''}`}>{projectName}</h3>
{hasCompleted && (
<button
className="completed-toggle-header"
onClick={(e) => {
e.stopPropagation()
toggleCompletedExpanded(projectName)
}}
title={isCompletedExpanded ? 'Скрыть выполненные' : 'Показать выполненные'}
>
<span className="completed-toggle-icon">
{isCompletedExpanded ? '▼' : '▶'}
</span>
</button>
)}
</div>
{/* Обычные задачи (включая бесконечные) */}
{group.notCompleted.length > 0 && (
<div className="task-group">
{group.notCompleted.map(task => renderTaskItem(task, false))}
</div>
)}
{/* Выполненные задачи */}
{hasCompleted && isCompletedExpanded && (
<div className="task-group completed-tasks">
{group.completed.map(task => renderTaskItem(task, true))}
</div>
)}
{group.notCompleted.length === 0 && !hasCompleted && (
<div className="empty-group">Нет задач в этой группе</div>
)}
</div>
)
})}
{/* Модальное окно для деталей задачи */}
{selectedTaskForDetail && (
<TaskDetail
taskId={selectedTaskForDetail}
onClose={handleCloseDetail}
onRefresh={onRefresh}
onTaskCompleted={() => setToast({ message: 'Задача выполнена', type: 'success' })}
onNavigate={onNavigate}
/>
)}
{/* Модальное окно для переноса задачи */}
{selectedTaskForPostpone && (() => {
const todayStr = formatDateToLocal(new Date())
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
const tomorrowStr = formatDateToLocal(tomorrow)
// Проверяем next_show_at задачи, а не значение в поле ввода
let nextShowAtStr = null
if (selectedTaskForPostpone.next_show_at) {
const nextShowAtDate = new Date(selectedTaskForPostpone.next_show_at)
nextShowAtStr = formatDateToLocal(nextShowAtDate)
}
const isToday = nextShowAtStr === todayStr
const isTomorrow = nextShowAtStr === tomorrowStr
const modalContent = (
<div className="task-postpone-modal-overlay" onClick={handlePostponeClose}>
<div className="task-postpone-modal" onClick={(e) => e.stopPropagation()}>
<div className="task-postpone-modal-header">
<h3>{selectedTaskForPostpone.name}</h3>
<button onClick={handlePostponeClose} className="task-postpone-close-button">
</button>
</div>
<div className="task-postpone-modal-content">
<div className="task-postpone-calendar">
<DayPicker
mode="single"
selected={postponeDate ? new Date(postponeDate + 'T00:00:00') : undefined}
onSelect={handleDateSelect}
onDayClick={handleDayClick}
disabled={{ before: (() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return today
})() }}
locale={ru}
/>
</div>
<div className="task-postpone-quick-buttons">
{!isToday && (
<button
onClick={handleTodayClick}
className="task-postpone-quick-button"
disabled={isPostponing}
>
Сегодня
</button>
)}
{!isTomorrow && (
<button
onClick={handleTomorrowClick}
className="task-postpone-quick-button"
disabled={isPostponing}
>
Завтра
</button>
)}
</div>
</div>
</div>
</div>
)
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent
})()}
</div>
)
}
export default TaskList