import React, { useState, useEffect, useMemo, useRef } from 'react' import { useAuth } from './auth/AuthContext' import TaskDetail from './TaskDetail' import Toast from './Toast' import './TaskList.css' const API_URL = '/api/tasks' function TaskList({ onNavigate, data, loading, backgroundLoading, 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) // Загружаем состояние раскрытия "Бесконечные" из localStorage (по умолчанию true) const [expandedInfinite, setExpandedInfinite] = useState(() => { try { const saved = localStorage.getItem('taskList_expandedInfinite') return saved ? JSON.parse(saved) : {} } catch { return {} } }) const [toast, setToast] = useState(null) useEffect(() => { if (data) { setTasks(data) } }, [data]) // Загрузка данных управляется из App.jsx через loadTabData // TaskList не инициирует загрузку самостоятельно const handleTaskClick = (task) => { onNavigate?.('task-form', { taskId: task.id }) } const handleCheckmarkClick = async (task, e) => { e.stopPropagation() const hasProgression = task.has_progression || task.progression_base != null const hasSubtasks = task.subtasks_count > 0 if (hasProgression || hasSubtasks) { // Открываем экран details setSelectedTaskForDetail(task.id) } else { // Отправляем задачу setIsCompleting(true) try { const response = await authFetch(`${API_URL}/${task.id}/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({}), }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.message || 'Ошибка при выполнении задачи') } // Показываем toast о выполнении задачи setToast({ message: 'Задача выполнена' }) // Обновляем список if (onRefresh) { onRefresh() } } catch (err) { console.error('Error completing task:', err) alert(err.message || 'Ошибка при выполнении задачи') } finally { setIsCompleting(false) } } } const handleCloseDetail = () => { setSelectedTaskForDetail(null) } const handleAddClick = () => { onNavigate?.('task-form', { taskId: undefined }) } // Функция для вычисления следующей даты по 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 } } // Форматирование даты в 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 handlePostponeClick = (task, e) => { e.stopPropagation() setSelectedTaskForPostpone(task) // Устанавливаем дату по умолчанию let defaultDate if (task.repetition_date) { // Для задач с repetition_date - вычисляем следующую подходящую дату const nextDate = calculateNextDateFromRepetitionDate(task.repetition_date) if (nextDate) { defaultDate = nextDate } } if (!defaultDate) { // Без repetition_date или если не удалось вычислить - завтра defaultDate = new Date() defaultDate.setDate(defaultDate.getDate() + 1) } defaultDate.setHours(0, 0, 0, 0) setPostponeDate(formatDateToLocal(defaultDate)) } const handlePostponeSubmit = async () => { if (!selectedTaskForPostpone || !postponeDate) return setIsPostponing(true) try { // Преобразуем дату в ISO формат с временем const dateObj = new Date(postponeDate) 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) alert(err.message || 'Ошибка при переносе задачи') } finally { setIsPostponing(false) } } const handlePostponeReset = async () => { if (!selectedTaskForPostpone) return setIsPostponing(true) try { const response = await authFetch(`${API_URL}/${selectedTaskForPostpone.id}/postpone`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ next_show_at: null }), }) 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 resetting postpone:', err) alert(err.message || 'Ошибка при сбросе переноса задачи') } finally { setIsPostponing(false) } } const handlePostponeClose = () => { setSelectedTaskForPostpone(null) setPostponeDate('') } const toggleCompletedExpanded = (projectName) => { setExpandedCompleted(prev => ({ ...prev, [projectName]: !prev[projectName] })) } const toggleInfiniteExpanded = (projectName) => { setExpandedInfinite(prev => { const newState = { ...prev, [projectName]: !prev[projectName] } // Сохраняем в localStorage try { localStorage.setItem('taskList_expandedInfinite', JSON.stringify(newState)) } catch (err) { console.error('Error saving expandedInfinite to localStorage:', err) } return newState }) } // Получаем все проекты из задачи (теперь они приходят в 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 parts = intervalStr.trim().split(/\s+/) if (parts.length < 1) return false const value = parseInt(parts[0], 10) return !isNaN(value) && value === 0 } // Функция для парсинга PostgreSQL INTERVAL и добавления к дате const addIntervalToDate = (date, intervalStr) => { if (!intervalStr) return null const result = new Date(date) // Парсим строку интервала (формат: "1 day", "2 hours", "3 months", etc.) const parts = intervalStr.trim().split(/\s+/) if (parts.length < 2) return null const value = parseInt(parts[0], 10) if (isNaN(value)) return null const unit = parts[1].toLowerCase() switch (unit) { case 'minute': case 'minutes': result.setMinutes(result.getMinutes() + value) break case 'hour': case 'hours': result.setHours(result.getHours() + value) break case 'day': case 'days': result.setDate(result.getDate() + value) break case 'week': case 'weeks': result.setDate(result.getDate() + value * 7) break case 'month': case 'months': result.setMonth(result.getMonth() + value) break case 'year': case 'years': result.setFullYear(result.getFullYear() + value) break default: return null } return result } // Группируем задачи по проектам const groupedTasks = useMemo(() => { const today = new Date() today.setHours(0, 0, 0, 0) const groups = {} tasks.forEach(task => { const projects = getTaskProjects(task) // Если у задачи нет проектов, добавляем в группу "Без проекта" if (projects.length === 0) { projects.push('Без проекта') } // Определяем, в какую группу попадает задача let isCompleted = false let isInfinite = false // Если next_show_at установлен, задача всегда в выполненных (если дата в будущем) // даже если она бесконечная (next_show_at приоритетнее всего) if (task.next_show_at) { const nextShowDate = new Date(task.next_show_at) nextShowDate.setHours(0, 0, 0, 0) isCompleted = nextShowDate.getTime() > today.getTime() isInfinite = false } else if (task.repetition_period && isZeroPeriod(task.repetition_period)) { // Если у задачи период повторения = 0 и нет next_show_at, она в бесконечных isInfinite = true isCompleted = false } else if (task.repetition_period) { // Если есть repetition_period (и он не 0), проверяем логику повторения // Используем last_completed_at + period let nextDueDate = null if (task.last_completed_at) { const lastCompleted = new Date(task.last_completed_at) nextDueDate = addIntervalToDate(lastCompleted, task.repetition_period) } if (nextDueDate) { // Округляем до начала дня nextDueDate.setHours(0, 0, 0, 0) // Если nextDueDate > today, то задача в выполненных isCompleted = nextDueDate.getTime() > today.getTime() } else { // Если не удалось определить дату, используем старую логику if (task.last_completed_at) { const completedDate = new Date(task.last_completed_at) completedDate.setHours(0, 0, 0, 0) isCompleted = completedDate.getTime() === today.getTime() } else { isCompleted = false } } } else { // Если нет ни repetition_period, ни repetition_date, используем старую логику if (task.last_completed_at) { const completedDate = new Date(task.last_completed_at) completedDate.setHours(0, 0, 0, 0) isCompleted = completedDate.getTime() === today.getTime() } else { isCompleted = false } } projects.forEach(projectName => { if (!groups[projectName]) { groups[projectName] = { notCompleted: [], completed: [], infinite: [] } } if (isInfinite) { groups[projectName].infinite.push(task) } else if (isCompleted) { groups[projectName].completed.push(task) } else { groups[projectName].notCompleted.push(task) } }) }) return groups }, [tasks]) const renderTaskItem = (task) => { const hasProgression = task.has_progression || task.progression_base != null const hasSubtasks = task.subtasks_count > 0 const showDetailOnCheckmark = hasProgression || hasSubtasks return (
Задач пока нет. Добавьте задачу через кнопку "Добавить".
{selectedTaskForPostpone.name}