All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1341 lines
54 KiB
JavaScript
1341 lines
54 KiB
JavaScript
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('')
|
||
const [savingProgressionTaskId, setSavingProgressionTaskId] = useState(null)
|
||
// Режим группировки: 'project' (по проекту - по умолчанию) или 'group' (по группе)
|
||
const [groupingMode, setGroupingMode] = useState(() => {
|
||
// Восстанавливаем из localStorage, по умолчанию 'project'
|
||
try {
|
||
const saved = localStorage.getItem('taskListGroupingMode')
|
||
return saved === 'group' ? 'group' : 'project'
|
||
} catch {
|
||
return 'project'
|
||
}
|
||
})
|
||
|
||
// Сохраняем режим группировки в localStorage при изменении
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem('taskListGroupingMode', groupingMode)
|
||
} catch {
|
||
// Игнорируем ошибки localStorage
|
||
}
|
||
}, [groupingMode])
|
||
|
||
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
|
||
}
|
||
|
||
// Для задач-закупок открываем экран закупок
|
||
const isPurchase = task.purchase_config_id != null
|
||
if (isPurchase) {
|
||
onNavigate?.('purchase', {
|
||
purchaseConfigId: task.purchase_config_id,
|
||
taskId: task.id,
|
||
taskName: task.name
|
||
})
|
||
return
|
||
}
|
||
|
||
// Для обычных задач открываем диалог подтверждения
|
||
setSelectedTaskForDetail(task.id)
|
||
}
|
||
|
||
const handleCloseDetail = (skipHistoryBack = false) => {
|
||
// Если skipHistoryBack = true (например, при навигации на форму редактирования),
|
||
// просто закрываем модальное окно без history.back()
|
||
if (!skipHistoryBack && historyPushedForDetailRef.current) {
|
||
window.history.back()
|
||
} else {
|
||
historyPushedForDetailRef.current = false
|
||
setSelectedTaskForDetail(null)
|
||
}
|
||
}
|
||
|
||
// Добавляем запись в историю при открытии модальных окон и обрабатываем "назад"
|
||
const historyPushedForDetailRef = useRef(false)
|
||
const historyPushedForPostponeRef = useRef(false)
|
||
const selectedTaskForDetailRef = useRef(selectedTaskForDetail)
|
||
const selectedTaskForPostponeRef = useRef(selectedTaskForPostpone)
|
||
|
||
// Обновляем refs при изменении значений
|
||
useEffect(() => {
|
||
selectedTaskForDetailRef.current = selectedTaskForDetail
|
||
selectedTaskForPostponeRef.current = selectedTaskForPostpone
|
||
}, [selectedTaskForDetail, selectedTaskForPostpone])
|
||
|
||
useEffect(() => {
|
||
if (selectedTaskForPostpone && !historyPushedForPostponeRef.current) {
|
||
// Добавляем запись в историю при открытии модального окна переноса
|
||
window.history.pushState({ modalOpen: true, type: 'task-postpone' }, '', window.location.href)
|
||
historyPushedForPostponeRef.current = true
|
||
} else if (!selectedTaskForPostpone) {
|
||
historyPushedForPostponeRef.current = false
|
||
}
|
||
|
||
if (selectedTaskForDetail && !historyPushedForDetailRef.current) {
|
||
// Добавляем запись в историю при открытии модального окна деталей задачи
|
||
window.history.pushState({ modalOpen: true, type: 'task-detail' }, '', window.location.href)
|
||
historyPushedForDetailRef.current = true
|
||
} else if (!selectedTaskForDetail) {
|
||
historyPushedForDetailRef.current = false
|
||
}
|
||
|
||
if (!selectedTaskForDetail && !selectedTaskForPostpone) return
|
||
|
||
const handlePopState = (event) => {
|
||
// Проверяем наличие модальных окон в DOM
|
||
const taskDetailModal = document.querySelector('.task-detail-modal-overlay')
|
||
const postponeModal = document.querySelector('.task-postpone-modal-overlay')
|
||
|
||
// Используем refs для получения актуального состояния
|
||
const currentTaskDetail = selectedTaskForDetailRef.current
|
||
const currentPostpone = selectedTaskForPostponeRef.current
|
||
|
||
// Сначала проверяем модальное окно переноса (если оно открыто поверх)
|
||
if (currentPostpone || postponeModal) {
|
||
setSelectedTaskForPostpone(null)
|
||
setPostponeDate('')
|
||
historyPushedForPostponeRef.current = false
|
||
// Возвращаем запись для модального окна деталей задачи, если оно было открыто
|
||
if (currentTaskDetail || taskDetailModal) {
|
||
window.history.pushState({ modalOpen: true, type: 'task-detail' }, '', window.location.href)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Если открыто модальное окно деталей задачи, закрываем его
|
||
if (currentTaskDetail || taskDetailModal) {
|
||
setSelectedTaskForDetail(null)
|
||
historyPushedForDetailRef.current = false
|
||
// Следующее нажатие "назад" обработается App.jsx нормально
|
||
return
|
||
}
|
||
}
|
||
|
||
window.addEventListener('popstate', handlePopState)
|
||
return () => {
|
||
window.removeEventListener('popstate', handlePopState)
|
||
}
|
||
}, [selectedTaskForDetail, selectedTaskForPostpone])
|
||
|
||
|
||
|
||
// Функция для вычисления следующей даты по 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)
|
||
const plannedStr = formatDateToLocal(defaultDate)
|
||
// Предвыбираем дату только если она не совпадает с текущей next_show_at (т.е. если чипс "По плану" будет показан)
|
||
let nextShowStr = null
|
||
if (task.next_show_at) {
|
||
const d = new Date(task.next_show_at)
|
||
d.setHours(0, 0, 0, 0)
|
||
nextShowStr = formatDateToLocal(d)
|
||
}
|
||
if (plannedStr !== nextShowStr) {
|
||
setPostponeDate(plannedStr)
|
||
} else {
|
||
setPostponeDate('')
|
||
}
|
||
}
|
||
|
||
const handlePostponeSubmit = async () => {
|
||
if (!selectedTaskForPostpone || !postponeDate) return
|
||
await handlePostponeSubmitWithDate(postponeDate)
|
||
}
|
||
|
||
const handlePostponeClose = () => {
|
||
// Если была добавлена запись в историю, удаляем её через history.back()
|
||
// Обработчик popstate закроет модальное окно и сбросит флаг
|
||
if (historyPushedForPostponeRef.current) {
|
||
window.history.back()
|
||
} else {
|
||
// Если записи не было, просто закрываем модальное окно
|
||
setSelectedTaskForPostpone(null)
|
||
setPostponeDate('')
|
||
}
|
||
}
|
||
|
||
const handleDateSelect = (date) => {
|
||
if (!date) return
|
||
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()
|
||
}
|
||
|
||
// Закрываем модальное окно и удаляем запись из истории, если она была добавлена
|
||
// Обработчик popstate закроет модальное окно и сбросит флаг
|
||
if (historyPushedForPostponeRef.current) {
|
||
window.history.back()
|
||
} else {
|
||
setSelectedTaskForPostpone(null)
|
||
setPostponeDate('')
|
||
}
|
||
} catch (err) {
|
||
console.error('Error postponing task:', err)
|
||
setToast({ message: err.message || 'Ошибка при переносе задачи', type: 'error' })
|
||
} finally {
|
||
setIsPostponing(false)
|
||
}
|
||
}
|
||
|
||
const handleWithoutDateClick = 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()
|
||
}
|
||
|
||
if (historyPushedForPostponeRef.current) {
|
||
window.history.back()
|
||
} else {
|
||
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]
|
||
}))
|
||
}
|
||
|
||
const handleProgressionChange = async (task, delta) => {
|
||
if (savingProgressionTaskId === task.id) return
|
||
|
||
const currentValue = task.draft_progression_value ?? 0
|
||
const newValue = currentValue + delta
|
||
|
||
setSavingProgressionTaskId(task.id)
|
||
try {
|
||
const response = await authFetch(`${API_URL}/${task.id}/draft`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
progression_value: newValue
|
||
}),
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Ошибка при сохранении прогрессии')
|
||
}
|
||
|
||
setTasks(prevTasks =>
|
||
prevTasks.map(t =>
|
||
t.id === task.id
|
||
? { ...t, draft_progression_value: newValue }
|
||
: t
|
||
)
|
||
)
|
||
} catch (err) {
|
||
console.error('Error saving progression:', err)
|
||
setToast({ message: err.message || 'Ошибка при сохранении прогрессии', type: 'error' })
|
||
} finally {
|
||
setSavingProgressionTaskId(null)
|
||
}
|
||
}
|
||
|
||
// Получаем все проекты из задачи (теперь они приходят в task.project_names)
|
||
const getTaskProjects = (task) => {
|
||
if (task.project_names && Array.isArray(task.project_names)) {
|
||
return task.project_names
|
||
}
|
||
return []
|
||
}
|
||
|
||
// Получаем название группы задачи (для режима группировки по группе)
|
||
const getTaskGroupName = (task) => {
|
||
// Если у задачи есть group_name - возвращаем его
|
||
if (task.group_name && task.group_name.trim()) {
|
||
return task.group_name.trim()
|
||
}
|
||
// Иначе возвращаем null - задача попадёт в "Остальные"
|
||
return null
|
||
}
|
||
|
||
// Функция для проверки, является ли период нулевым
|
||
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 => {
|
||
let groupKeys = []
|
||
|
||
if (groupingMode === 'project') {
|
||
// Группировка по проекту (текущее поведение)
|
||
groupKeys = getTaskProjects(task)
|
||
if (groupKeys.length === 0) {
|
||
groupKeys = ['Остальные'] // Было 'Без проекта'
|
||
}
|
||
} else {
|
||
// Группировка по group_name
|
||
const groupName = getTaskGroupName(task)
|
||
groupKeys = groupName ? [groupName] : ['Остальные']
|
||
}
|
||
|
||
// Определяем, в какую группу попадает задача
|
||
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 {
|
||
// Задачи без даты (next_show_at = null) идут в выполненные
|
||
isCompleted = true
|
||
}
|
||
|
||
groupKeys.forEach(groupKey => {
|
||
if (!groups[groupKey]) {
|
||
groups[groupKey] = {
|
||
notCompleted: [],
|
||
completed: []
|
||
}
|
||
}
|
||
|
||
if (isCompleted) {
|
||
groups[groupKey].completed.push(task)
|
||
} else {
|
||
// Бесконечные задачи теперь идут в обычный список
|
||
groups[groupKey].notCompleted.push(task)
|
||
}
|
||
})
|
||
})
|
||
|
||
// Сортируем задачи внутри каждой группы проекта
|
||
Object.keys(groups).forEach(projectName => {
|
||
const group = groups[projectName]
|
||
|
||
// Сортируем невыполненные задачи: по алфавиту (name ASC), затем по id ASC
|
||
group.notCompleted.sort((a, b) => {
|
||
const nameCompare = (a.name || '').localeCompare(b.name || '')
|
||
if (nameCompare !== 0) {
|
||
return nameCompare
|
||
}
|
||
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, groupingMode])
|
||
|
||
// Сортируем проекты: сначала с невыполненными задачами, потом без них
|
||
// Группа "Без проекта" всегда последняя в своей категории
|
||
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 isOthersA = a === 'Остальные'
|
||
const isOthersB = b === 'Остальные'
|
||
|
||
// "Остальные" всегда последняя в своей категории
|
||
if (isOthersA && !isOthersB) return 1
|
||
if (!isOthersA && isOthersB) 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 isPurchase = task.purchase_config_id != null
|
||
const showDetailOnCheckmark = !isTest && !isPurchase
|
||
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 ? 'Запустить тест' : (isPurchase ? 'Открыть закупки' : (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>
|
||
) : isPurchase ? (
|
||
<svg
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<path d="M2 7h20l-2 13a2 2 0 0 1-2 1.5H6a2 2 0 0 1-2-1.5L2 7z"></path>
|
||
<path d="M9 7V6a3 3 0 0 1 6 0v1"></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.draft_subtasks_count != null && task.draft_subtasks_count > 0
|
||
? `(${task.draft_subtasks_count}/${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 && (
|
||
<span
|
||
className={`task-progression-capsule ${savingProgressionTaskId === task.id ? 'task-progression-capsule--saving' : ''}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
if (savingProgressionTaskId !== task.id) {
|
||
handleProgressionChange(task, task.progression_base)
|
||
}
|
||
}}
|
||
title="Задача с прогрессией"
|
||
>
|
||
<svg
|
||
className="task-progression-icon"
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<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>
|
||
{task.draft_progression_value != null && (
|
||
<span className="task-progression-value">{task.draft_progression_value}</span>
|
||
)}
|
||
</span>
|
||
)}
|
||
</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)}
|
||
/>
|
||
{/* Кнопка переключения группировки */}
|
||
<button
|
||
type="button"
|
||
className="task-grouping-toggle"
|
||
onClick={() => setGroupingMode(prev => prev === 'project' ? 'group' : 'project')}
|
||
title={groupingMode === 'project' ? 'Группировка по проекту' : 'Группировка по группе'}
|
||
>
|
||
{groupingMode === 'project' ? (
|
||
// Иконка "папка" для группировки по проекту (filled)
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||
</svg>
|
||
) : (
|
||
// Иконка "тег" для группировки по группе (filled, с вырезом под дырку)
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd">
|
||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z M7 5.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z"/>
|
||
</svg>
|
||
)}
|
||
</button>
|
||
{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 className="completed-toggle-header" style={{ visibility: 'hidden', pointerEvents: 'none' }}>
|
||
<span className="completed-toggle-icon">▶</span>
|
||
</div>
|
||
)}
|
||
</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
|
||
// Не показывать «Сегодня», если next_show_at уже сегодня или в прошлом
|
||
const showTodayChip = !nextShowAtStr || nextShowAtStr > todayStr
|
||
|
||
// Дата "по плану" (repetition_date / repetition_period или завтра)
|
||
const task = selectedTaskForPostpone
|
||
let plannedDate
|
||
const now = new Date()
|
||
now.setHours(0, 0, 0, 0)
|
||
if (task.repetition_date) {
|
||
const nextDate = calculateNextDateFromRepetitionDate(task.repetition_date)
|
||
if (nextDate) plannedDate = nextDate
|
||
} else if (task.repetition_period && !isZeroPeriod(task.repetition_period)) {
|
||
const nextDate = calculateNextDateFromRepetitionPeriod(task.repetition_period)
|
||
if (nextDate) plannedDate = nextDate
|
||
}
|
||
if (!plannedDate) {
|
||
plannedDate = new Date(now)
|
||
plannedDate.setDate(plannedDate.getDate() + 1)
|
||
}
|
||
plannedDate.setHours(0, 0, 0, 0)
|
||
const plannedDateStr = formatDateToLocal(plannedDate)
|
||
const plannedNorm = plannedDateStr.slice(0, 10)
|
||
const nextShowNorm = nextShowAtStr ? String(nextShowAtStr).slice(0, 10) : ''
|
||
// Показываем кнопку, если текущий next_show_at не совпадает с датой по плану
|
||
const isCurrentDatePlanned = plannedNorm && nextShowNorm && plannedNorm === nextShowNorm
|
||
|
||
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">
|
||
{showTodayChip && (
|
||
<button
|
||
onClick={handleTodayClick}
|
||
className="task-postpone-quick-button"
|
||
disabled={isPostponing}
|
||
>
|
||
Сегодня
|
||
</button>
|
||
)}
|
||
{!isTomorrow && (
|
||
<button
|
||
onClick={handleTomorrowClick}
|
||
className="task-postpone-quick-button"
|
||
disabled={isPostponing}
|
||
>
|
||
Завтра
|
||
</button>
|
||
)}
|
||
{!isCurrentDatePlanned && (
|
||
<button
|
||
onClick={() => handlePostponeSubmitWithDate(plannedDateStr)}
|
||
className="task-postpone-quick-button"
|
||
disabled={isPostponing}
|
||
>
|
||
По плану
|
||
</button>
|
||
)}
|
||
{selectedTaskForPostpone?.next_show_at && (
|
||
<button
|
||
onClick={handleWithoutDateClick}
|
||
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
|
||
|