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 } 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 } const [selectedBoardId, setSelectedBoardIdState] = useState(getInitialBoardId) const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) const [boardsLoading, setBoardsLoading] = useState(!hasBoardsCache()) 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) // Refs для закрытия диалогов кнопкой "Назад" const historyPushedForDetailRef = useRef(false) const historyPushedForPostponeRef = useRef(false) const selectedItemForDetailRef = useRef(selectedItemForDetail) const selectedItemForPostponeRef = useRef(selectedItemForPostpone) const setSelectedBoardId = (boardId) => { setSelectedBoardIdState(boardId) try { if (boardId) { localStorage.setItem(SELECTED_BOARD_KEY, String(boardId)) } else { localStorage.removeItem(SELECTED_BOARD_KEY) } } catch (err) {} } // Загрузка досок const fetchBoards = async (showLoading = true) => { if (showLoading) setBoardsLoading(true) 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(() => { const hasCache = hasBoardsCache() fetchBoards(!hasCache) initialFetchDoneRef.current = true }, []) // Загрузка при смене доски useEffect(() => { if (selectedBoardId) { fetchItems(selectedBoardId) } else { setItems([]) setLoading(false) } }, [selectedBoardId]) // Рефреш при возврате на таб useEffect(() => { if (isActive && !prevIsActiveRef.current && initialFetchDoneRef.current) { fetchBoards(false) if (selectedBoardId) fetchItems(selectedBoardId) } prevIsActiveRef.current = isActive }, [isActive]) // Рефреш по триггеру useEffect(() => { if (refreshTrigger > 0) { fetchBoards(false) if (selectedBoardId) fetchItems(selectedBoardId) } }, [refreshTrigger]) // initialBoardId useEffect(() => { if (initialBoardId) { setSelectedBoardId(initialBoardId) } }, [initialBoardId]) // Синхронизация 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]) // Фильтрация и группировка на клиенте 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: [] } } 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 } 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) } const handleCloseDetail = () => { if (historyPushedForDetailRef.current) { window.history.back() } else { historyPushedForDetailRef.current = false setSelectedItemForDetail(null) } } // Модалка переноса const handlePostponeClose = () => { if (historyPushedForPostponeRef.current) { window.history.back() } else { historyPushedForPostponeRef.current = false setSelectedItemForPostpone(null) setPostponeDate('') } } 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 (
{boards.length === 0 && !boardsLoading && (

Нет досок

Создайте доску, чтобы начать добавлять товары

)} {selectedBoardId && error && ( )} {selectedBoardId && !error && ( <> {loading && items.length === 0 && (
)} {!loading && items.length === 0 && (

Нет товаров

)} {groupNames.map(groupName => { const group = groupedItems[groupName] const hasActive = group.active.length > 0 const hasFuture = group.future.length > 0 const isFutureExpanded = expandedFuture[groupName] return (
toggleFuture(groupName) : undefined} >

{groupName}

{hasFuture ? ( ) : (
)}
{hasActive && (
{group.active.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 (
setSelectedItemForDetail(item.id)} >
{ e.stopPropagation() setSelectedItemForDetail(item.id) }} title="Выполнить" >
{item.name}
{dateDisplay && (
{dateDisplay}
)}
) })}
)} {hasFuture && isFutureExpanded && (
{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 (
setSelectedItemForDetail(item.id)} >
{ e.stopPropagation() setSelectedItemForDetail(item.id) }} title="Выполнить" >
{item.name}
{dateDisplay && (
{dateDisplay}
)}
)})}
)}
) })} )} {/* Модалка выполнения */} {selectedItemForDetail && ( 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 // Показываем "Сегодня" если нет даты, или дата строго в будущем (сегодня и прошлое — не показываем) const showTodayChip = !nextShowAtStr || nextShowAtStr > todayStr // Дата "по плану" 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 = (
e.stopPropagation()}>

{selectedItemForPostpone.name}

{ const today = new Date() today.setHours(0, 0, 0, 0) return today })() }} locale={ru} />
{showTodayChip && ( )} {!isTomorrow && ( )} {!isCurrentDatePlanned && ( )} {selectedItemForPostpone?.next_show_at && ( )}
) return typeof document !== 'undefined' ? createPortal(modalContent, document.body) : modalContent })()} {toast && ( setToast(null)} /> )}
) } export default ShoppingList