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 [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null)
|
|
|
|
|
|
const [isCompleting, setIsCompleting] = useState(false)
|
|
|
|
|
|
const [expandedCompleted, setExpandedCompleted] = useState({})
|
2026-01-06 15:56:52 +03:00
|
|
|
|
const [selectedTaskForPostpone, setSelectedTaskForPostpone] = useState(null)
|
|
|
|
|
|
const [postponeDate, setPostponeDate] = useState('')
|
|
|
|
|
|
const [isPostponing, setIsPostponing] = useState(false)
|
2026-01-04 19:37:59 +03:00
|
|
|
|
const [toast, setToast] = useState(null)
|
2026-01-10 19:17:03 +03:00
|
|
|
|
const dateInputRef = useRef(null)
|
2026-01-04 19:37:59 +03:00
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
2026-01-10 19:17:03 +03:00
|
|
|
|
// Всегда открываем диалог подтверждения
|
|
|
|
|
|
setSelectedTaskForDetail(task.id)
|
2026-01-04 19:37:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleCloseDetail = () => {
|
|
|
|
|
|
setSelectedTaskForDetail(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleAddClick = () => {
|
|
|
|
|
|
onNavigate?.('task-form', { taskId: undefined })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-06 16:41:54 +03:00
|
|
|
|
// Функция для вычисления следующей даты по 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 13:51:50 +03:00
|
|
|
|
// Функция для вычисления следующей даты по 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':
|
2026-01-11 15:09:32 +03:00
|
|
|
|
case 'mins':
|
|
|
|
|
|
case 'min':
|
2026-01-09 13:51:50 +03:00
|
|
|
|
nextDate.setMinutes(nextDate.getMinutes() + value)
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'hour':
|
|
|
|
|
|
case 'hours':
|
2026-01-11 15:09:32 +03:00
|
|
|
|
case 'hrs':
|
|
|
|
|
|
case 'hr':
|
2026-01-09 13:51:50 +03:00
|
|
|
|
nextDate.setHours(nextDate.getHours() + value)
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'day':
|
|
|
|
|
|
case 'days':
|
2026-01-11 15:09:32 +03:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
2026-01-09 13:51:50 +03:00
|
|
|
|
break
|
|
|
|
|
|
case 'week':
|
|
|
|
|
|
case 'weeks':
|
2026-01-11 15:09:32 +03:00
|
|
|
|
case 'wks':
|
|
|
|
|
|
case 'wk':
|
2026-01-09 13:51:50 +03:00
|
|
|
|
nextDate.setDate(nextDate.getDate() + value * 7)
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'month':
|
|
|
|
|
|
case 'months':
|
2026-01-11 15:09:32 +03:00
|
|
|
|
case 'mons':
|
|
|
|
|
|
case 'mon':
|
2026-01-09 13:51:50 +03:00
|
|
|
|
nextDate.setMonth(nextDate.getMonth() + value)
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'year':
|
|
|
|
|
|
case 'years':
|
2026-01-11 15:09:32 +03:00
|
|
|
|
case 'yrs':
|
|
|
|
|
|
case 'yr':
|
2026-01-09 13:51:50 +03:00
|
|
|
|
nextDate.setFullYear(nextDate.getFullYear() + value)
|
|
|
|
|
|
break
|
|
|
|
|
|
default:
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nextDate
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-06 16:41:54 +03:00
|
|
|
|
// Форматирование даты в 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}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 19:17:03 +03:00
|
|
|
|
// Форматирование даты для отображения с понятными названиями
|
|
|
|
|
|
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}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-06 15:56:52 +03:00
|
|
|
|
const handlePostponeClick = (task, e) => {
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
setSelectedTaskForPostpone(task)
|
2026-01-06 16:41:54 +03:00
|
|
|
|
|
|
|
|
|
|
// Устанавливаем дату по умолчанию
|
|
|
|
|
|
let defaultDate
|
2026-01-09 13:51:50 +03:00
|
|
|
|
const now = new Date()
|
|
|
|
|
|
now.setHours(0, 0, 0, 0)
|
|
|
|
|
|
|
2026-01-06 16:41:54 +03:00
|
|
|
|
if (task.repetition_date) {
|
|
|
|
|
|
// Для задач с repetition_date - вычисляем следующую подходящую дату
|
|
|
|
|
|
const nextDate = calculateNextDateFromRepetitionDate(task.repetition_date)
|
|
|
|
|
|
if (nextDate) {
|
|
|
|
|
|
defaultDate = nextDate
|
|
|
|
|
|
}
|
2026-01-09 13:51:50 +03:00
|
|
|
|
} else if (task.repetition_period && !isZeroPeriod(task.repetition_period)) {
|
|
|
|
|
|
// Для задач с repetition_period (не нулевым) - вычисляем следующую дату
|
|
|
|
|
|
const nextDate = calculateNextDateFromRepetitionPeriod(task.repetition_period)
|
|
|
|
|
|
if (nextDate) {
|
|
|
|
|
|
defaultDate = nextDate
|
|
|
|
|
|
}
|
2026-01-06 16:41:54 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!defaultDate) {
|
2026-01-09 13:51:50 +03:00
|
|
|
|
// Без repetition_date/repetition_period или если не удалось вычислить - завтра
|
|
|
|
|
|
defaultDate = new Date(now)
|
2026-01-06 16:41:54 +03:00
|
|
|
|
defaultDate.setDate(defaultDate.getDate() + 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
defaultDate.setHours(0, 0, 0, 0)
|
|
|
|
|
|
setPostponeDate(formatDateToLocal(defaultDate))
|
2026-01-06 15:56:52 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handlePostponeSubmit = async () => {
|
|
|
|
|
|
if (!selectedTaskForPostpone || !postponeDate) return
|
2026-01-09 13:51:50 +03:00
|
|
|
|
await handlePostponeSubmitWithDate(postponeDate)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handlePostponeClose = () => {
|
|
|
|
|
|
setSelectedTaskForPostpone(null)
|
|
|
|
|
|
setPostponeDate('')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-01-06 15:56:52 +03:00
|
|
|
|
|
|
|
|
|
|
setIsPostponing(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Преобразуем дату в ISO формат с временем
|
2026-01-09 13:51:50 +03:00
|
|
|
|
const dateObj = new Date(dateToUse)
|
2026-01-06 15:56:52 +03:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 19:37:59 +03:00
|
|
|
|
const toggleCompletedExpanded = (projectName) => {
|
|
|
|
|
|
setExpandedCompleted(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[projectName]: !prev[projectName]
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-06 14:54:37 +03:00
|
|
|
|
// Получаем все проекты из задачи (теперь они приходят в task.project_names)
|
2026-01-04 19:37:59 +03:00
|
|
|
|
const getTaskProjects = (task) => {
|
2026-01-06 14:54:37 +03:00
|
|
|
|
if (task.project_names && Array.isArray(task.project_names)) {
|
|
|
|
|
|
return task.project_names
|
2026-01-04 19:37:59 +03:00
|
|
|
|
}
|
2026-01-06 14:54:37 +03:00
|
|
|
|
return []
|
2026-01-04 19:37:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Функция для проверки, является ли период нулевым
|
|
|
|
|
|
const isZeroPeriod = (intervalStr) => {
|
|
|
|
|
|
if (!intervalStr) return false
|
|
|
|
|
|
|
2026-01-09 14:12:06 +03:00
|
|
|
|
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+/)
|
2026-01-04 19:37:59 +03:00
|
|
|
|
if (parts.length < 1) return false
|
|
|
|
|
|
|
|
|
|
|
|
const value = parseInt(parts[0], 10)
|
|
|
|
|
|
return !isNaN(value) && value === 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 14:12:06 +03:00
|
|
|
|
// Функция для проверки, является ли 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 19:37:59 +03:00
|
|
|
|
// Группируем задачи по проектам
|
|
|
|
|
|
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-07 15:43:20 +03:00
|
|
|
|
// Используем только next_show_at для группировки
|
2026-01-06 15:56:52 +03:00
|
|
|
|
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
|
2026-01-04 19:37:59 +03:00
|
|
|
|
} else {
|
2026-01-09 14:12:06 +03:00
|
|
|
|
// Бесконечная задача: 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
|
|
|
|
|
|
isInfinite = (hasZeroPeriod && hasZeroDate) || (hasZeroPeriod && !task.repetition_date)
|
2026-01-07 15:43:20 +03:00
|
|
|
|
isCompleted = false
|
2026-01-04 19:37:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
projects.forEach(projectName => {
|
|
|
|
|
|
if (!groups[projectName]) {
|
|
|
|
|
|
groups[projectName] = {
|
|
|
|
|
|
notCompleted: [],
|
2026-01-09 14:12:06 +03:00
|
|
|
|
completed: []
|
2026-01-04 19:37:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 14:12:06 +03:00
|
|
|
|
if (isCompleted) {
|
2026-01-04 19:37:59 +03:00
|
|
|
|
groups[projectName].completed.push(task)
|
|
|
|
|
|
} else {
|
2026-01-09 14:12:06 +03:00
|
|
|
|
// Бесконечные задачи теперь идут в обычный список
|
2026-01-04 19:37:59 +03:00
|
|
|
|
groups[projectName].notCompleted.push(task)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return groups
|
2026-01-06 14:54:37 +03:00
|
|
|
|
}, [tasks])
|
2026-01-04 19:37:59 +03:00
|
|
|
|
|
2026-01-09 14:17:17 +03:00
|
|
|
|
const renderTaskItem = (task, isCompleted = false) => {
|
2026-01-06 14:54:37 +03:00
|
|
|
|
const hasProgression = task.has_progression || task.progression_base != null
|
|
|
|
|
|
const hasSubtasks = task.subtasks_count > 0
|
2026-01-04 19:37:59 +03:00
|
|
|
|
const showDetailOnCheckmark = hasProgression || hasSubtasks
|
2026-01-09 14:12:06 +03:00
|
|
|
|
|
|
|
|
|
|
// Проверяем бесконечную задачу: 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)
|
|
|
|
|
|
|
2026-01-10 19:17:03 +03:00
|
|
|
|
// Одноразовая задача: когда оба поля null/undefined
|
|
|
|
|
|
const isOneTime = (task.repetition_period == null || task.repetition_period === undefined) &&
|
|
|
|
|
|
(task.repetition_date == null || task.repetition_date === undefined)
|
|
|
|
|
|
|
2026-01-09 14:12:06 +03:00
|
|
|
|
// Отладка для задачи "Ролик"
|
|
|
|
|
|
if (task.name === 'Ролик') {
|
|
|
|
|
|
console.log('Task "Ролик":', {
|
|
|
|
|
|
name: task.name,
|
|
|
|
|
|
repetition_period: task.repetition_period,
|
|
|
|
|
|
repetition_date: task.repetition_date,
|
|
|
|
|
|
next_show_at: task.next_show_at,
|
|
|
|
|
|
hasZeroPeriod,
|
|
|
|
|
|
hasZeroDate,
|
|
|
|
|
|
isInfinite
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-01-04 19:37:59 +03:00
|
|
|
|
|
|
|
|
|
|
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>
|
2026-01-06 14:54:37 +03:00
|
|
|
|
<div className="task-name-container">
|
2026-01-06 15:56:52 +03:00
|
|
|
|
<div className="task-name-wrapper">
|
|
|
|
|
|
<div className="task-name">
|
|
|
|
|
|
{task.name}
|
|
|
|
|
|
{hasSubtasks && (
|
|
|
|
|
|
<span className="task-subtasks-count">(+{task.subtasks_count})</span>
|
|
|
|
|
|
)}
|
2026-01-09 14:12:06 +03:00
|
|
|
|
<span className="task-badge-bar">
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-01-10 19:17:03 +03:00
|
|
|
|
{isOneTime && (
|
|
|
|
|
|
<svg
|
|
|
|
|
|
className="task-onetime-icon"
|
|
|
|
|
|
width="16"
|
|
|
|
|
|
height="16"
|
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
strokeWidth="2"
|
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
|
title="Одноразовая задача"
|
|
|
|
|
|
>
|
|
|
|
|
|
<circle cx="12" cy="12" r="10"></circle>
|
|
|
|
|
|
<line x1="12" y1="8" x2="12" y2="14"></line>
|
|
|
|
|
|
<circle cx="12" cy="18" r="1"></circle>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
)}
|
2026-01-09 14:12:06 +03:00
|
|
|
|
</span>
|
2026-01-06 15:56:52 +03:00
|
|
|
|
</div>
|
2026-01-09 14:17:17 +03:00
|
|
|
|
{/* Показываем дату только для выполненных задач */}
|
|
|
|
|
|
{isCompleted && task.next_show_at && (() => {
|
2026-01-06 15:56:52 +03:00
|
|
|
|
const showDate = new Date(task.next_show_at)
|
2026-01-07 15:43:20 +03:00
|
|
|
|
// Нормализуем дату: устанавливаем время в 00:00:00 в локальном времени
|
|
|
|
|
|
const showDateNormalized = new Date(showDate.getFullYear(), showDate.getMonth(), showDate.getDate())
|
|
|
|
|
|
|
2026-01-06 15:56:52 +03:00
|
|
|
|
const today = new Date()
|
2026-01-07 15:43:20 +03:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-01-06 15:56:52 +03:00
|
|
|
|
|
|
|
|
|
|
let dateText
|
2026-01-07 15:43:20 +03:00
|
|
|
|
if (showDateNormalized.getTime() === tomorrowNormalized.getTime()) {
|
2026-01-06 15:56:52 +03:00
|
|
|
|
dateText = 'Завтра'
|
|
|
|
|
|
} else {
|
|
|
|
|
|
dateText = showDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="task-next-show-date">
|
|
|
|
|
|
{dateText}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})()}
|
2026-01-06 14:54:37 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-04 19:37:59 +03:00
|
|
|
|
<div className="task-actions">
|
2026-01-06 15:56:52 +03:00
|
|
|
|
<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>
|
2026-01-04 19:37:59 +03:00
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
|
|
{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 isCompletedExpanded = expandedCompleted[projectName]
|
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>
|
|
|
|
|
|
|
2026-01-09 14:12:06 +03:00
|
|
|
|
{/* Обычные задачи (включая бесконечные) */}
|
2026-01-04 19:37:59 +03:00
|
|
|
|
{group.notCompleted.length > 0 && (
|
|
|
|
|
|
<div className="task-group">
|
2026-01-09 14:17:17 +03:00
|
|
|
|
{group.notCompleted.map(task => renderTaskItem(task, false))}
|
2026-01-04 19:37:59 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-06 15:56:52 +03:00
|
|
|
|
{/* Выполненные задачи */}
|
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">
|
2026-01-09 14:17:17 +03:00
|
|
|
|
{group.completed.map(task => renderTaskItem(task, true))}
|
2026-01-04 19:37:59 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-09 14:12:06 +03:00
|
|
|
|
{group.notCompleted.length === 0 && !hasCompleted && (
|
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: 'Задача выполнена' })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-01-06 15:56:52 +03:00
|
|
|
|
|
|
|
|
|
|
{/* Модальное окно для переноса задачи */}
|
2026-01-09 13:51:50 +03:00
|
|
|
|
{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
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<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-input-group">
|
|
|
|
|
|
<input
|
2026-01-10 19:17:03 +03:00
|
|
|
|
ref={dateInputRef}
|
2026-01-09 13:51:50 +03:00
|
|
|
|
type="date"
|
|
|
|
|
|
value={postponeDate}
|
|
|
|
|
|
onChange={(e) => setPostponeDate(e.target.value)}
|
|
|
|
|
|
className="task-postpone-input"
|
|
|
|
|
|
min={new Date().toISOString().split('T')[0]}
|
|
|
|
|
|
/>
|
2026-01-10 19:17:03 +03:00
|
|
|
|
<div
|
|
|
|
|
|
className="task-postpone-display-date"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
// Открываем календарь при клике
|
|
|
|
|
|
if (dateInputRef.current) {
|
|
|
|
|
|
if (typeof dateInputRef.current.showPicker === 'function') {
|
|
|
|
|
|
dateInputRef.current.showPicker()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Fallback для браузеров без showPicker
|
|
|
|
|
|
dateInputRef.current.focus()
|
|
|
|
|
|
dateInputRef.current.click()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{postponeDate ? formatDateForDisplay(postponeDate) : 'Выберите дату'}
|
|
|
|
|
|
</div>
|
2026-01-09 13:51:50 +03:00
|
|
|
|
{postponeDate && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={handlePostponeSubmit}
|
|
|
|
|
|
disabled={isPostponing || !postponeDate}
|
|
|
|
|
|
className="task-postpone-submit-checkmark"
|
|
|
|
|
|
>
|
|
|
|
|
|
✓
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</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>
|
2026-01-06 15:56:52 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-09 13:51:50 +03:00
|
|
|
|
)
|
|
|
|
|
|
})()}
|
2026-01-04 19:37:59 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default TaskList
|
|
|
|
|
|
|