2026-01-04 19:37:59 +03:00
|
|
|
|
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 [taskDetails, setTaskDetails] = useState({})
|
|
|
|
|
|
const [loadingDetails, setLoadingDetails] = useState(false)
|
|
|
|
|
|
const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null)
|
|
|
|
|
|
const [isCompleting, setIsCompleting] = useState(false)
|
|
|
|
|
|
const [expandedCompleted, setExpandedCompleted] = useState({})
|
2026-01-06 14:31:00 +03:00
|
|
|
|
// Загружаем состояние раскрытия "Бесконечные" из localStorage (по умолчанию true)
|
|
|
|
|
|
const [expandedInfinite, setExpandedInfinite] = useState(() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const saved = localStorage.getItem('taskList_expandedInfinite')
|
|
|
|
|
|
return saved ? JSON.parse(saved) : {}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return {}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-01-04 19:37:59 +03:00
|
|
|
|
const [toast, setToast] = useState(null)
|
|
|
|
|
|
|
|
|
|
|
|
// Для отслеживания изменений в списке задач (чтобы не перезагружать детали без необходимости)
|
|
|
|
|
|
const lastTaskIdsRef = useRef('')
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (data) {
|
|
|
|
|
|
setTasks(data)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [data])
|
|
|
|
|
|
|
|
|
|
|
|
// Загрузка данных управляется из App.jsx через loadTabData
|
|
|
|
|
|
// TaskList не инициирует загрузку самостоятельно
|
|
|
|
|
|
|
|
|
|
|
|
// Загружаем детали для всех задач
|
|
|
|
|
|
// Оптимизация: загружаем только если список задач изменился (по id и last_completed_at)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!tasks || tasks.length === 0) {
|
|
|
|
|
|
setTaskDetails({})
|
|
|
|
|
|
lastTaskIdsRef.current = ''
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Создаем ключ из id и last_completed_at всех задач
|
|
|
|
|
|
const taskKey = tasks.map(t => `${t.id}:${t.last_completed_at || ''}`).sort().join(',')
|
|
|
|
|
|
|
|
|
|
|
|
// Если ключ не изменился, не перезагружаем детали
|
|
|
|
|
|
if (taskKey === lastTaskIdsRef.current) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
lastTaskIdsRef.current = taskKey
|
|
|
|
|
|
|
|
|
|
|
|
const loadTaskDetails = async () => {
|
|
|
|
|
|
// Не показываем индикатор загрузки если детали уже есть (фоновое обновление)
|
|
|
|
|
|
const hasExistingDetails = Object.keys(taskDetails).length > 0
|
|
|
|
|
|
if (!hasExistingDetails) {
|
|
|
|
|
|
setLoadingDetails(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const detailPromises = tasks.map(async (task) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await authFetch(`${API_URL}/${task.id}`)
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const detail = await response.json()
|
|
|
|
|
|
return { taskId: task.id, detail }
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(`Error loading task detail for ${task.id}:`, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const details = await Promise.all(detailPromises)
|
|
|
|
|
|
const detailsMap = {}
|
|
|
|
|
|
details.forEach(item => {
|
|
|
|
|
|
if (item) {
|
|
|
|
|
|
detailsMap[item.taskId] = item.detail
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
setTaskDetails(detailsMap)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Error loading task details:', err)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingDetails(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loadTaskDetails()
|
|
|
|
|
|
}, [tasks, authFetch])
|
|
|
|
|
|
|
|
|
|
|
|
const handleTaskClick = (task) => {
|
|
|
|
|
|
onNavigate?.('task-form', { taskId: task.id })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleCheckmarkClick = async (task, e) => {
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
|
|
|
|
|
|
const detail = taskDetails[task.id]
|
|
|
|
|
|
const hasProgression = detail?.task?.progression_base != null
|
|
|
|
|
|
const hasSubtasks = detail?.subtasks && detail.subtasks.length > 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 })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const toggleCompletedExpanded = (projectName) => {
|
|
|
|
|
|
setExpandedCompleted(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[projectName]: !prev[projectName]
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-06 14:31:00 +03:00
|
|
|
|
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
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 19:37:59 +03:00
|
|
|
|
// Получаем все проекты из наград задачи и подзадач
|
|
|
|
|
|
const getTaskProjects = (task) => {
|
|
|
|
|
|
const projects = new Set()
|
|
|
|
|
|
const detail = taskDetails[task.id]
|
|
|
|
|
|
|
|
|
|
|
|
if (detail) {
|
|
|
|
|
|
// Проекты из основной задачи
|
|
|
|
|
|
if (detail.rewards) {
|
|
|
|
|
|
detail.rewards.forEach(reward => {
|
|
|
|
|
|
if (reward.project_name) {
|
|
|
|
|
|
projects.add(reward.project_name)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Проекты из подзадач
|
|
|
|
|
|
if (detail.subtasks) {
|
|
|
|
|
|
detail.subtasks.forEach(subtask => {
|
|
|
|
|
|
if (subtask.rewards) {
|
|
|
|
|
|
subtask.rewards.forEach(reward => {
|
|
|
|
|
|
if (reward.project_name) {
|
|
|
|
|
|
projects.add(reward.project_name)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Array.from(projects)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Функция для проверки, является ли период нулевым
|
|
|
|
|
|
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
|
2026-01-06 14:31:00 +03:00
|
|
|
|
let isInfinite = false
|
2026-01-04 19:37:59 +03:00
|
|
|
|
|
2026-01-06 14:31:00 +03:00
|
|
|
|
// Если у задачи период повторения = 0, она в бесконечных
|
2026-01-04 19:37:59 +03:00
|
|
|
|
if (task.repetition_period && isZeroPeriod(task.repetition_period)) {
|
2026-01-06 14:31:00 +03:00
|
|
|
|
isInfinite = true
|
2026-01-04 19:37:59 +03:00
|
|
|
|
isCompleted = false
|
|
|
|
|
|
} else if (task.repetition_period) {
|
|
|
|
|
|
// Если есть repetition_period (и он не 0), проверяем логику повторения
|
|
|
|
|
|
if (task.last_completed_at) {
|
|
|
|
|
|
const lastCompleted = new Date(task.last_completed_at)
|
|
|
|
|
|
const nextDueDate = addIntervalToDate(lastCompleted, task.repetition_period)
|
|
|
|
|
|
|
|
|
|
|
|
if (nextDueDate) {
|
|
|
|
|
|
// Округляем до начала дня
|
|
|
|
|
|
nextDueDate.setHours(0, 0, 0, 0)
|
|
|
|
|
|
|
|
|
|
|
|
// Если nextDueDate > today, то задача в выполненных
|
|
|
|
|
|
isCompleted = nextDueDate.getTime() > today.getTime()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Если не удалось распарсить интервал, используем старую логику
|
|
|
|
|
|
const completedDate = new Date(task.last_completed_at)
|
|
|
|
|
|
completedDate.setHours(0, 0, 0, 0)
|
|
|
|
|
|
isCompleted = completedDate.getTime() === today.getTime()
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Если нет last_completed_at, то в обычной группе
|
|
|
|
|
|
isCompleted = false
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Если repetition_period == null, используем старую логику
|
|
|
|
|
|
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: [],
|
2026-01-06 14:31:00 +03:00
|
|
|
|
completed: [],
|
|
|
|
|
|
infinite: []
|
2026-01-04 19:37:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-06 14:31:00 +03:00
|
|
|
|
if (isInfinite) {
|
|
|
|
|
|
groups[projectName].infinite.push(task)
|
|
|
|
|
|
} else if (isCompleted) {
|
2026-01-04 19:37:59 +03:00
|
|
|
|
groups[projectName].completed.push(task)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
groups[projectName].notCompleted.push(task)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return groups
|
|
|
|
|
|
}, [tasks, taskDetails])
|
|
|
|
|
|
|
|
|
|
|
|
const renderTaskItem = (task) => {
|
|
|
|
|
|
const detail = taskDetails[task.id]
|
|
|
|
|
|
const hasProgression = detail?.task?.progression_base != null
|
|
|
|
|
|
const hasSubtasks = detail?.subtasks && detail.subtasks.length > 0
|
|
|
|
|
|
const showDetailOnCheckmark = hasProgression || hasSubtasks
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={task.id}
|
|
|
|
|
|
className="task-item"
|
|
|
|
|
|
onClick={() => handleTaskClick(task)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="task-item-content">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''}`}
|
|
|
|
|
|
onClick={(e) => handleCheckmarkClick(task, e)}
|
|
|
|
|
|
title={showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу'}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="task-name">{task.name}</div>
|
|
|
|
|
|
<div className="task-actions">
|
|
|
|
|
|
<span className="task-completed-count">{task.completed}</span>
|
|
|
|
|
|
</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
|
|
|
|
|
|
|
|
|
|
|
|
// Показываем загрузку только если:
|
|
|
|
|
|
// 1. Идет загрузка (loading = true)
|
|
|
|
|
|
// 2. Это не фоновая загрузка (backgroundLoading = false)
|
|
|
|
|
|
// 3. Данных нет (hasData = false)
|
|
|
|
|
|
// Это предотвращает показ загрузки при переключении табов, когда данные уже есть
|
|
|
|
|
|
if (loading && !backgroundLoading && !hasData) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="task-list">
|
|
|
|
|
|
<div className="loading">Загрузка...</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const projectNames = Object.keys(groupedTasks).sort()
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="task-list">
|
|
|
|
|
|
{toast && (
|
|
|
|
|
|
<Toast
|
|
|
|
|
|
message={toast.message}
|
|
|
|
|
|
onClose={() => setToast(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button onClick={handleAddClick} className="add-task-button">
|
|
|
|
|
|
Добавить
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{loadingDetails && tasks.length > 0 && (
|
|
|
|
|
|
<div className="loading-details">Загрузка деталей задач...</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
|
2026-01-06 14:31:00 +03:00
|
|
|
|
const hasInfinite = group.infinite.length > 0
|
|
|
|
|
|
const isCompletedExpanded = expandedCompleted[projectName]
|
|
|
|
|
|
// По умолчанию бесконечные раскрыты (true), если не сохранено иное
|
|
|
|
|
|
const isInfiniteExpanded = expandedInfinite[projectName] !== undefined
|
|
|
|
|
|
? expandedInfinite[projectName]
|
|
|
|
|
|
: true
|
2026-01-04 19:37:59 +03:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={projectName} className="project-group">
|
|
|
|
|
|
<div className="project-group-header">
|
|
|
|
|
|
<h3 className="project-group-title">{projectName}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{group.notCompleted.length > 0 && (
|
|
|
|
|
|
<div className="task-group">
|
|
|
|
|
|
{group.notCompleted.map(renderTaskItem)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-06 14:31:00 +03:00
|
|
|
|
{hasInfinite && (
|
|
|
|
|
|
<div className="completed-section">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="completed-toggle"
|
|
|
|
|
|
onClick={() => toggleInfiniteExpanded(projectName)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="completed-toggle-icon">
|
|
|
|
|
|
{isInfiniteExpanded ? '▼' : '▶'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span>Бесконечные ({group.infinite.length})</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{isInfiniteExpanded && (
|
|
|
|
|
|
<div className="task-group completed-tasks">
|
|
|
|
|
|
{group.infinite.map(renderTaskItem)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-04 19:37:59 +03:00
|
|
|
|
{hasCompleted && (
|
|
|
|
|
|
<div className="completed-section">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="completed-toggle"
|
|
|
|
|
|
onClick={() => toggleCompletedExpanded(projectName)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="completed-toggle-icon">
|
2026-01-06 14:31:00 +03:00
|
|
|
|
{isCompletedExpanded ? '▼' : '▶'}
|
2026-01-04 19:37:59 +03:00
|
|
|
|
</span>
|
|
|
|
|
|
<span>Выполненные ({group.completed.length})</span>
|
|
|
|
|
|
</button>
|
2026-01-06 14:31:00 +03:00
|
|
|
|
{isCompletedExpanded && (
|
2026-01-04 19:37:59 +03:00
|
|
|
|
<div className="task-group completed-tasks">
|
|
|
|
|
|
{group.completed.map(renderTaskItem)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-06 14:31:00 +03:00
|
|
|
|
{group.notCompleted.length === 0 && !hasCompleted && !hasInfinite && (
|
2026-01-04 19:37:59 +03:00
|
|
|
|
<div className="empty-group">Нет задач в этой группе</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Модальное окно для деталей задачи */}
|
|
|
|
|
|
{selectedTaskForDetail && (
|
|
|
|
|
|
<TaskDetail
|
|
|
|
|
|
taskId={selectedTaskForDetail}
|
|
|
|
|
|
onClose={handleCloseDetail}
|
|
|
|
|
|
onRefresh={onRefresh}
|
|
|
|
|
|
onTaskCompleted={() => setToast({ message: 'Задача выполнена' })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default TaskList
|
|
|
|
|
|
|