2026-03-08 16:11:08 +03:00
|
|
|
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
|
|
|
|
|
import { createPortal } from 'react-dom'
|
|
|
|
|
|
import { useAuth } from './auth/AuthContext'
|
|
|
|
|
|
import BoardSelector from './BoardSelector'
|
|
|
|
|
|
import ShoppingItemDetail from './ShoppingItemDetail'
|
|
|
|
|
|
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'
|
|
|
|
|
|
import './ShoppingList.css'
|
|
|
|
|
|
|
|
|
|
|
|
const BOARDS_CACHE_KEY = 'shopping_boards_cache'
|
|
|
|
|
|
const ITEMS_CACHE_KEY = 'shopping_items_cache'
|
|
|
|
|
|
const SELECTED_BOARD_KEY = 'shopping_selected_board_id'
|
|
|
|
|
|
|
|
|
|
|
|
// Форматирование даты в YYYY-MM-DD (локальное время)
|
|
|
|
|
|
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 ''
|
|
|
|
|
|
const date = new Date(dateStr)
|
|
|
|
|
|
if (isNaN(date.getTime())) return ''
|
|
|
|
|
|
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
now.setHours(0, 0, 0, 0)
|
|
|
|
|
|
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
|
|
|
|
|
|
|
|
|
|
|
const diffDays = Math.floor((target - now) / (1000 * 60 * 60 * 24))
|
|
|
|
|
|
|
|
|
|
|
|
if (diffDays === 0) return 'Сегодня'
|
|
|
|
|
|
if (diffDays === 1) return 'Завтра'
|
|
|
|
|
|
if (diffDays === -1) return 'Вчера'
|
|
|
|
|
|
|
|
|
|
|
|
if (diffDays > 0 && diffDays <= 7) {
|
|
|
|
|
|
const dayNames = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']
|
|
|
|
|
|
return dayNames[target.getDay()]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const monthNames = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
|
|
|
|
|
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
|
|
|
|
|
|
|
|
|
|
|
|
if (target.getFullYear() === now.getFullYear()) {
|
|
|
|
|
|
return `${target.getDate()} ${monthNames[target.getMonth()]}`
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${target.getDate()} ${monthNames[target.getMonth()]} ${target.getFullYear()}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Вычисление следующей даты по 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 'day': case 'days':
|
|
|
|
|
|
if (value % 7 === 0 && value >= 7) {
|
|
|
|
|
|
nextDate.setDate(nextDate.getDate() + value)
|
|
|
|
|
|
} 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
|
|
|
|
|
|
case 'hour': case 'hours': case 'hrs': case 'hr':
|
|
|
|
|
|
nextDate.setHours(nextDate.getHours() + value)
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'minute': case 'minutes': case 'mins': case 'min':
|
|
|
|
|
|
nextDate.setMinutes(nextDate.getMinutes() + value)
|
|
|
|
|
|
break
|
|
|
|
|
|
default:
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
return nextDate
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initialBoardId = null, boardDeleted = false }) {
|
|
|
|
|
|
const { authFetch } = useAuth()
|
|
|
|
|
|
const [boards, setBoards] = useState([])
|
|
|
|
|
|
|
|
|
|
|
|
const getInitialBoardId = () => {
|
|
|
|
|
|
if (initialBoardId) return initialBoardId
|
|
|
|
|
|
try {
|
|
|
|
|
|
const saved = localStorage.getItem(SELECTED_BOARD_KEY)
|
|
|
|
|
|
if (saved) {
|
|
|
|
|
|
const boardId = parseInt(saved, 10)
|
|
|
|
|
|
if (!isNaN(boardId)) return boardId
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {}
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 19:30:56 +03:00
|
|
|
|
const hasBoardsCache = () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const cached = localStorage.getItem(BOARDS_CACHE_KEY)
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
|
const data = JSON.parse(cached)
|
|
|
|
|
|
return !!(data.boards && data.boards.length >= 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 16:11:08 +03:00
|
|
|
|
const [selectedBoardId, setSelectedBoardIdState] = useState(getInitialBoardId)
|
|
|
|
|
|
const [items, setItems] = useState([])
|
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
2026-03-08 19:30:56 +03:00
|
|
|
|
const [boardsLoading, setBoardsLoading] = useState(!hasBoardsCache())
|
2026-03-08 16:11:08 +03:00
|
|
|
|
const [error, setError] = useState('')
|
|
|
|
|
|
const [selectedItemForDetail, setSelectedItemForDetail] = useState(null)
|
|
|
|
|
|
const [selectedItemForPostpone, setSelectedItemForPostpone] = useState(null)
|
|
|
|
|
|
const [postponeDate, setPostponeDate] = useState('')
|
|
|
|
|
|
const [isPostponing, setIsPostponing] = useState(false)
|
|
|
|
|
|
const [toast, setToast] = useState(null)
|
|
|
|
|
|
const fetchingRef = useRef(false)
|
|
|
|
|
|
const initialFetchDoneRef = useRef(false)
|
|
|
|
|
|
const prevIsActiveRef = useRef(isActive)
|
|
|
|
|
|
|
2026-03-08 19:25:35 +03:00
|
|
|
|
// Refs для закрытия диалогов кнопкой "Назад"
|
|
|
|
|
|
const historyPushedForDetailRef = useRef(false)
|
|
|
|
|
|
const historyPushedForPostponeRef = useRef(false)
|
|
|
|
|
|
const selectedItemForDetailRef = useRef(selectedItemForDetail)
|
|
|
|
|
|
const selectedItemForPostponeRef = useRef(selectedItemForPostpone)
|
|
|
|
|
|
|
2026-03-08 16:11:08 +03:00
|
|
|
|
const setSelectedBoardId = (boardId) => {
|
|
|
|
|
|
setSelectedBoardIdState(boardId)
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (boardId) {
|
|
|
|
|
|
localStorage.setItem(SELECTED_BOARD_KEY, String(boardId))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
localStorage.removeItem(SELECTED_BOARD_KEY)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Загрузка досок
|
2026-03-08 19:30:56 +03:00
|
|
|
|
const fetchBoards = async (showLoading = true) => {
|
|
|
|
|
|
if (showLoading) setBoardsLoading(true)
|
2026-03-08 16:11:08 +03:00
|
|
|
|
try {
|
|
|
|
|
|
const res = await authFetch('/api/shopping/boards')
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
|
const boardsList = Array.isArray(data) ? data : []
|
|
|
|
|
|
setBoards(boardsList)
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
localStorage.setItem(BOARDS_CACHE_KEY, JSON.stringify({ boards: boardsList }))
|
|
|
|
|
|
} catch (err) {}
|
|
|
|
|
|
|
|
|
|
|
|
if (boardDeleted || !boardsList.some(b => b.id === selectedBoardId)) {
|
|
|
|
|
|
if (boardsList.length > 0) {
|
|
|
|
|
|
setSelectedBoardId(boardsList[0].id)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setSelectedBoardId(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError('Ошибка загрузки досок')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBoardsLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Загрузка товаров
|
|
|
|
|
|
const fetchItems = async (boardId) => {
|
|
|
|
|
|
if (!boardId || fetchingRef.current) return
|
|
|
|
|
|
fetchingRef.current = true
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await authFetch(`/api/shopping/boards/${boardId}/items`)
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
|
setItems(Array.isArray(data) ? data : [])
|
|
|
|
|
|
try {
|
|
|
|
|
|
localStorage.setItem(`${ITEMS_CACHE_KEY}_${boardId}`, JSON.stringify(data))
|
|
|
|
|
|
} catch (err) {}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setError('Ошибка загрузки товаров')
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError('Ошибка загрузки товаров')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
fetchingRef.current = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Загрузка из кэша
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const cached = localStorage.getItem(BOARDS_CACHE_KEY)
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
|
const data = JSON.parse(cached)
|
|
|
|
|
|
if (data.boards) setBoards(data.boards)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {}
|
|
|
|
|
|
|
|
|
|
|
|
if (selectedBoardId) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const cached = localStorage.getItem(`${ITEMS_CACHE_KEY}_${selectedBoardId}`)
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
|
setItems(JSON.parse(cached) || [])
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
// Начальная загрузка
|
|
|
|
|
|
useEffect(() => {
|
2026-03-08 19:30:56 +03:00
|
|
|
|
const hasCache = hasBoardsCache()
|
|
|
|
|
|
fetchBoards(!hasCache)
|
2026-03-08 16:11:08 +03:00
|
|
|
|
initialFetchDoneRef.current = true
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
// Загрузка при смене доски
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (selectedBoardId) {
|
|
|
|
|
|
fetchItems(selectedBoardId)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setItems([])
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [selectedBoardId])
|
|
|
|
|
|
|
|
|
|
|
|
// Рефреш при возврате на таб
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (isActive && !prevIsActiveRef.current && initialFetchDoneRef.current) {
|
2026-03-08 19:30:56 +03:00
|
|
|
|
fetchBoards(false)
|
2026-03-08 16:11:08 +03:00
|
|
|
|
if (selectedBoardId) fetchItems(selectedBoardId)
|
|
|
|
|
|
}
|
|
|
|
|
|
prevIsActiveRef.current = isActive
|
|
|
|
|
|
}, [isActive])
|
|
|
|
|
|
|
|
|
|
|
|
// Рефреш по триггеру
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (refreshTrigger > 0) {
|
2026-03-08 19:30:56 +03:00
|
|
|
|
fetchBoards(false)
|
2026-03-08 16:11:08 +03:00
|
|
|
|
if (selectedBoardId) fetchItems(selectedBoardId)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [refreshTrigger])
|
|
|
|
|
|
|
|
|
|
|
|
// initialBoardId
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (initialBoardId) {
|
|
|
|
|
|
setSelectedBoardId(initialBoardId)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [initialBoardId])
|
|
|
|
|
|
|
2026-03-08 19:25:35 +03:00
|
|
|
|
// Синхронизация refs для диалогов
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
selectedItemForDetailRef.current = selectedItemForDetail
|
|
|
|
|
|
selectedItemForPostponeRef.current = selectedItemForPostpone
|
|
|
|
|
|
}, [selectedItemForDetail, selectedItemForPostpone])
|
|
|
|
|
|
|
|
|
|
|
|
// Закрытие диалогов кнопкой "Назад" (browser history API)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (selectedItemForPostpone && !historyPushedForPostponeRef.current) {
|
|
|
|
|
|
window.history.pushState({ modalOpen: true, type: 'shopping-postpone' }, '', window.location.href)
|
|
|
|
|
|
historyPushedForPostponeRef.current = true
|
|
|
|
|
|
} else if (!selectedItemForPostpone) {
|
|
|
|
|
|
historyPushedForPostponeRef.current = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (selectedItemForDetail && !historyPushedForDetailRef.current) {
|
|
|
|
|
|
window.history.pushState({ modalOpen: true, type: 'shopping-detail' }, '', window.location.href)
|
|
|
|
|
|
historyPushedForDetailRef.current = true
|
|
|
|
|
|
} else if (!selectedItemForDetail) {
|
|
|
|
|
|
historyPushedForDetailRef.current = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!selectedItemForDetail && !selectedItemForPostpone) return
|
|
|
|
|
|
|
|
|
|
|
|
const handlePopState = () => {
|
|
|
|
|
|
const currentDetail = selectedItemForDetailRef.current
|
|
|
|
|
|
const currentPostpone = selectedItemForPostponeRef.current
|
|
|
|
|
|
|
|
|
|
|
|
if (currentPostpone) {
|
|
|
|
|
|
setSelectedItemForPostpone(null)
|
|
|
|
|
|
setPostponeDate('')
|
|
|
|
|
|
historyPushedForPostponeRef.current = false
|
|
|
|
|
|
if (currentDetail) {
|
|
|
|
|
|
window.history.pushState({ modalOpen: true, type: 'shopping-detail' }, '', window.location.href)
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (currentDetail) {
|
|
|
|
|
|
setSelectedItemForDetail(null)
|
|
|
|
|
|
historyPushedForDetailRef.current = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('popstate', handlePopState)
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
window.removeEventListener('popstate', handlePopState)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [selectedItemForDetail, selectedItemForPostpone])
|
|
|
|
|
|
|
2026-03-08 16:11:08 +03:00
|
|
|
|
// Фильтрация и группировка на клиенте
|
|
|
|
|
|
const groupedItems = useMemo(() => {
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
now.setHours(0, 0, 0, 0)
|
|
|
|
|
|
const todayEnd = new Date(now)
|
|
|
|
|
|
todayEnd.setHours(23, 59, 59, 999)
|
|
|
|
|
|
|
|
|
|
|
|
const groups = {}
|
|
|
|
|
|
|
|
|
|
|
|
items.forEach(item => {
|
|
|
|
|
|
const groupKey = item.group_name || 'Остальные'
|
|
|
|
|
|
if (!groups[groupKey]) {
|
|
|
|
|
|
groups[groupKey] = { active: [], future: [] }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 19:36:45 +03:00
|
|
|
|
if (!item.next_show_at) {
|
|
|
|
|
|
groups[groupKey].future.push(item)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const showAt = new Date(item.next_show_at)
|
|
|
|
|
|
if (showAt > todayEnd) {
|
|
|
|
|
|
groups[groupKey].future.push(item)
|
|
|
|
|
|
return
|
2026-03-08 16:11:08 +03:00
|
|
|
|
}
|
|
|
|
|
|
groups[groupKey].active.push(item)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Сортируем future по next_show_at ASC
|
|
|
|
|
|
Object.values(groups).forEach(group => {
|
|
|
|
|
|
group.future.sort((a, b) => {
|
|
|
|
|
|
if (!a.next_show_at) return 1
|
|
|
|
|
|
if (!b.next_show_at) return -1
|
|
|
|
|
|
return new Date(a.next_show_at) - new Date(b.next_show_at)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return groups
|
|
|
|
|
|
}, [items])
|
|
|
|
|
|
|
|
|
|
|
|
const [expandedFuture, setExpandedFuture] = useState({})
|
|
|
|
|
|
|
|
|
|
|
|
const handleBoardChange = (boardId) => {
|
|
|
|
|
|
setSelectedBoardId(boardId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleBoardEdit = () => {
|
|
|
|
|
|
if (selectedBoardId) {
|
|
|
|
|
|
const board = boards.find(b => b.id === selectedBoardId)
|
|
|
|
|
|
if (board?.is_owner) {
|
|
|
|
|
|
onNavigate('shopping-board-form', { boardId: selectedBoardId })
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (window.confirm('Покинуть доску?')) {
|
|
|
|
|
|
authFetch(`/api/shopping/boards/${selectedBoardId}/leave`, { method: 'POST' })
|
|
|
|
|
|
.then(res => {
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
fetchBoards()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleAddBoard = () => {
|
|
|
|
|
|
onNavigate('shopping-board-form')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleRefresh = () => {
|
|
|
|
|
|
if (selectedBoardId) fetchItems(selectedBoardId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 16:39:23 +03:00
|
|
|
|
const handleCloseDetail = (skipHistoryBack = false) => {
|
|
|
|
|
|
if (!skipHistoryBack && historyPushedForDetailRef.current) {
|
2026-03-08 19:25:35 +03:00
|
|
|
|
window.history.back()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
historyPushedForDetailRef.current = false
|
|
|
|
|
|
setSelectedItemForDetail(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 22:37:03 +03:00
|
|
|
|
const openPostpone = (item) => {
|
|
|
|
|
|
setSelectedItemForPostpone(item)
|
|
|
|
|
|
// Предвыбираем дату "по плану" если она не совпадает с текущей next_show_at
|
|
|
|
|
|
const now2 = new Date()
|
|
|
|
|
|
now2.setHours(0, 0, 0, 0)
|
|
|
|
|
|
let planned
|
|
|
|
|
|
if (item.repetition_period) {
|
|
|
|
|
|
planned = calculateNextDateFromRepetitionPeriod(item.repetition_period)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!planned) {
|
|
|
|
|
|
planned = new Date(now2)
|
|
|
|
|
|
planned.setDate(planned.getDate() + 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
planned.setHours(0, 0, 0, 0)
|
|
|
|
|
|
const plannedStr = formatDateToLocal(planned)
|
|
|
|
|
|
let nextShowStr = null
|
|
|
|
|
|
if (item.next_show_at) {
|
|
|
|
|
|
nextShowStr = formatDateToLocal(new Date(item.next_show_at))
|
|
|
|
|
|
}
|
|
|
|
|
|
if (plannedStr !== nextShowStr) {
|
|
|
|
|
|
setPostponeDate(plannedStr)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setPostponeDate('')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 16:11:08 +03:00
|
|
|
|
// Модалка переноса
|
|
|
|
|
|
const handlePostponeClose = () => {
|
2026-03-08 19:25:35 +03:00
|
|
|
|
if (historyPushedForPostponeRef.current) {
|
|
|
|
|
|
window.history.back()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
historyPushedForPostponeRef.current = false
|
|
|
|
|
|
setSelectedItemForPostpone(null)
|
|
|
|
|
|
setPostponeDate('')
|
|
|
|
|
|
}
|
2026-03-08 16:11:08 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleDateSelect = (date) => {
|
|
|
|
|
|
if (date) {
|
|
|
|
|
|
setPostponeDate(formatDateToLocal(date))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleDayClick = (date) => {
|
|
|
|
|
|
if (date) {
|
|
|
|
|
|
const dateStr = formatDateToLocal(date)
|
|
|
|
|
|
handlePostponeSubmitWithDate(dateStr)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handlePostponeSubmitWithDate = async (dateStr) => {
|
|
|
|
|
|
if (!selectedItemForPostpone || !dateStr) return
|
|
|
|
|
|
|
|
|
|
|
|
setIsPostponing(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const nextShowAt = new Date(dateStr + 'T00:00:00')
|
|
|
|
|
|
const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ next_show_at: nextShowAt.toISOString() })
|
|
|
|
|
|
})
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
setToast({ message: 'Дата перенесена', type: 'success' })
|
|
|
|
|
|
handleRefresh()
|
|
|
|
|
|
handlePostponeClose()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setToast({ message: 'Ошибка переноса', type: 'error' })
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setToast({ message: 'Ошибка переноса', type: 'error' })
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsPostponing(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleTodayClick = () => {
|
|
|
|
|
|
handlePostponeSubmitWithDate(formatDateToLocal(new Date()))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleTomorrowClick = () => {
|
|
|
|
|
|
const tomorrow = new Date()
|
|
|
|
|
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
|
|
|
|
|
handlePostponeSubmitWithDate(formatDateToLocal(tomorrow))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleWithoutDateClick = async () => {
|
|
|
|
|
|
if (!selectedItemForPostpone) return
|
|
|
|
|
|
setIsPostponing(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ next_show_at: null })
|
|
|
|
|
|
})
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
setToast({ message: 'Дата убрана', type: 'success' })
|
|
|
|
|
|
handleRefresh()
|
|
|
|
|
|
handlePostponeClose()
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setToast({ message: 'Ошибка', type: 'error' })
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsPostponing(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const groupNames = useMemo(() => {
|
|
|
|
|
|
const names = Object.keys(groupedItems)
|
|
|
|
|
|
return names.sort((a, b) => {
|
|
|
|
|
|
const groupA = groupedItems[a]
|
|
|
|
|
|
const groupB = groupedItems[b]
|
|
|
|
|
|
const hasActiveA = groupA.active.length > 0
|
|
|
|
|
|
const hasActiveB = groupB.active.length > 0
|
|
|
|
|
|
|
|
|
|
|
|
if (hasActiveA && !hasActiveB) return -1
|
|
|
|
|
|
if (!hasActiveA && hasActiveB) return 1
|
|
|
|
|
|
|
|
|
|
|
|
if (a === 'Остальные') return 1
|
|
|
|
|
|
if (b === 'Остальные') return -1
|
|
|
|
|
|
return a.localeCompare(b, 'ru')
|
|
|
|
|
|
})
|
|
|
|
|
|
}, [groupedItems])
|
|
|
|
|
|
|
|
|
|
|
|
const toggleFuture = (groupName) => {
|
|
|
|
|
|
setExpandedFuture(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[groupName]: !prev[groupName]
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="shopping-list">
|
2026-03-11 09:41:27 +03:00
|
|
|
|
<div className="shopping-header">
|
|
|
|
|
|
<BoardSelector
|
|
|
|
|
|
boards={boards}
|
|
|
|
|
|
selectedBoardId={selectedBoardId}
|
|
|
|
|
|
onBoardChange={handleBoardChange}
|
|
|
|
|
|
onBoardEdit={handleBoardEdit}
|
|
|
|
|
|
onAddBoard={handleAddBoard}
|
|
|
|
|
|
loading={boardsLoading}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button className="shopping-close-btn" onClick={() => window.history.back()}>✕</button>
|
|
|
|
|
|
</div>
|
2026-03-08 16:11:08 +03:00
|
|
|
|
|
|
|
|
|
|
{boards.length === 0 && !boardsLoading && (
|
|
|
|
|
|
<div className="shopping-empty">
|
|
|
|
|
|
<p>Нет досок</p>
|
|
|
|
|
|
<p className="shopping-empty-hint">Создайте доску, чтобы начать добавлять товары</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{selectedBoardId && error && (
|
|
|
|
|
|
<LoadingError onRetry={handleRefresh} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{selectedBoardId && !error && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{loading && items.length === 0 && (
|
|
|
|
|
|
<div className="shopping-loading">
|
|
|
|
|
|
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && items.length === 0 && (
|
|
|
|
|
|
<div className="shopping-empty">
|
|
|
|
|
|
<p>Нет товаров</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{groupNames.map(groupName => {
|
|
|
|
|
|
const group = groupedItems[groupName]
|
|
|
|
|
|
const hasActive = group.active.length > 0
|
|
|
|
|
|
const hasFuture = group.future.length > 0
|
|
|
|
|
|
const isFutureExpanded = expandedFuture[groupName]
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={groupName} className={`project-group ${!hasActive ? 'project-group-no-tasks' : ''}`}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`project-group-header ${hasFuture ? 'project-group-header-clickable' : ''}`}
|
|
|
|
|
|
onClick={hasFuture ? () => toggleFuture(groupName) : undefined}
|
|
|
|
|
|
>
|
|
|
|
|
|
<h3 className={`project-group-title ${!hasActive ? 'project-group-title-empty' : ''}`}>{groupName}</h3>
|
|
|
|
|
|
{hasFuture ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="completed-toggle-header"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
toggleFuture(groupName)
|
|
|
|
|
|
}}
|
|
|
|
|
|
title={isFutureExpanded ? 'Скрыть ожидающие' : 'Показать ожидающие'}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="completed-toggle-icon">
|
|
|
|
|
|
{isFutureExpanded ? '▼' : '▶'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="completed-toggle-header" style={{ visibility: 'hidden', pointerEvents: 'none' }}>
|
|
|
|
|
|
<span className="completed-toggle-icon">▶</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{hasActive && (
|
|
|
|
|
|
<div className="task-group">
|
|
|
|
|
|
{group.active.map(item => {
|
|
|
|
|
|
let dateDisplay = null
|
|
|
|
|
|
if (item.next_show_at) {
|
2026-03-09 21:04:21 +03:00
|
|
|
|
const itemDate = new Date(item.next_show_at)
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
now.setHours(0, 0, 0, 0)
|
|
|
|
|
|
const target = new Date(itemDate.getFullYear(), itemDate.getMonth(), itemDate.getDate())
|
|
|
|
|
|
if (target > now) {
|
|
|
|
|
|
dateDisplay = formatDateForDisplay(item.next_show_at)
|
|
|
|
|
|
}
|
2026-03-08 16:11:08 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
className="task-item"
|
|
|
|
|
|
onClick={() => setSelectedItemForDetail(item.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="task-item-content">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="task-checkmark"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
setSelectedItemForDetail(item.id)
|
|
|
|
|
|
}}
|
|
|
|
|
|
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="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-container">
|
|
|
|
|
|
<div className="task-name-wrapper">
|
|
|
|
|
|
<div className="task-name">{item.name}</div>
|
|
|
|
|
|
{dateDisplay && (
|
|
|
|
|
|
<div className="task-next-show-date">{dateDisplay}</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="task-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="task-postpone-button"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation()
|
2026-03-10 22:37:03 +03:00
|
|
|
|
openPostpone(item)
|
2026-03-08 16:11:08 +03:00
|
|
|
|
}}
|
|
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{hasFuture && isFutureExpanded && (
|
|
|
|
|
|
<div className="task-group completed-tasks">
|
2026-03-10 15:48:09 +03:00
|
|
|
|
{group.future.map(item => {
|
|
|
|
|
|
let dateDisplay = null
|
|
|
|
|
|
if (item.next_show_at) {
|
|
|
|
|
|
const itemDate = new Date(item.next_show_at)
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
now.setHours(0, 0, 0, 0)
|
|
|
|
|
|
const target = new Date(itemDate.getFullYear(), itemDate.getMonth(), itemDate.getDate())
|
|
|
|
|
|
if (target > now) {
|
|
|
|
|
|
dateDisplay = formatDateForDisplay(item.next_show_at)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-08 16:11:08 +03:00
|
|
|
|
<div
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
className="task-item"
|
|
|
|
|
|
onClick={() => setSelectedItemForDetail(item.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="task-item-content">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="task-checkmark"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
setSelectedItemForDetail(item.id)
|
|
|
|
|
|
}}
|
|
|
|
|
|
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="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-container">
|
|
|
|
|
|
<div className="task-name-wrapper">
|
|
|
|
|
|
<div className="task-name">{item.name}</div>
|
2026-03-10 15:48:09 +03:00
|
|
|
|
{dateDisplay && (
|
|
|
|
|
|
<div className="task-next-show-date">{dateDisplay}</div>
|
|
|
|
|
|
)}
|
2026-03-08 16:11:08 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="task-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="task-postpone-button"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
setSelectedItemForPostpone(item)
|
|
|
|
|
|
}}
|
|
|
|
|
|
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>
|
2026-03-10 15:48:09 +03:00
|
|
|
|
)})}
|
2026-03-08 16:11:08 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Модалка выполнения */}
|
|
|
|
|
|
{selectedItemForDetail && (
|
|
|
|
|
|
<ShoppingItemDetail
|
|
|
|
|
|
itemId={selectedItemForDetail}
|
2026-03-08 19:25:35 +03:00
|
|
|
|
onClose={handleCloseDetail}
|
2026-03-08 16:11:08 +03:00
|
|
|
|
onRefresh={handleRefresh}
|
|
|
|
|
|
onItemCompleted={() => setToast({ message: 'Товар выполнен', type: 'success' })}
|
|
|
|
|
|
onNavigate={onNavigate}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Модалка переноса */}
|
|
|
|
|
|
{selectedItemForPostpone && (() => {
|
|
|
|
|
|
const todayStr = formatDateToLocal(new Date())
|
|
|
|
|
|
const tomorrow = new Date()
|
|
|
|
|
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
|
|
|
|
|
const tomorrowStr = formatDateToLocal(tomorrow)
|
|
|
|
|
|
|
|
|
|
|
|
let nextShowAtStr = null
|
|
|
|
|
|
if (selectedItemForPostpone.next_show_at) {
|
|
|
|
|
|
const nextShowAtDate = new Date(selectedItemForPostpone.next_show_at)
|
|
|
|
|
|
nextShowAtStr = formatDateToLocal(nextShowAtDate)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isTomorrow = nextShowAtStr === tomorrowStr
|
2026-03-08 19:37:06 +03:00
|
|
|
|
// Показываем "Сегодня" если нет даты, или дата строго в будущем (сегодня и прошлое — не показываем)
|
|
|
|
|
|
const showTodayChip = !nextShowAtStr || nextShowAtStr > todayStr
|
2026-03-08 16:11:08 +03:00
|
|
|
|
|
|
|
|
|
|
// Дата "по плану"
|
|
|
|
|
|
const item = selectedItemForPostpone
|
|
|
|
|
|
let plannedDate
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
now.setHours(0, 0, 0, 0)
|
|
|
|
|
|
if (item.repetition_period) {
|
|
|
|
|
|
const nextDate = calculateNextDateFromRepetitionPeriod(item.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) : ''
|
|
|
|
|
|
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>{selectedItemForPostpone.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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{selectedItemForPostpone?.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
|
|
|
|
|
|
})()}
|
|
|
|
|
|
|
|
|
|
|
|
{toast && (
|
|
|
|
|
|
<Toast
|
|
|
|
|
|
message={toast.message}
|
|
|
|
|
|
type={toast.type}
|
|
|
|
|
|
onClose={() => setToast(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default ShoppingList
|