import React, { useState, useEffect, useRef, useMemo } from 'react' import { createPortal } from 'react-dom' import { useAuth } from './auth/AuthContext' import ShoppingItemDetail from './ShoppingItemDetail' 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' // Форматирование даты в 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.round((target - now) / (1000 * 60 * 60 * 24)) if (diffDays === 0) return 'Сегодня' if (diffDays === 1) return 'Завтра' const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'] return `${date.getDate()} ${months[date.getMonth()]}` } const calculateNextDateFromRepetitionPeriod = (periodStr) => { if (!periodStr) return null const match = periodStr.match(/(\d+)\s*(day|week|mon|year)/i) if (!match) return null const value = parseInt(match[1], 10) const unit = match[2].toLowerCase() const next = new Date() next.setHours(0, 0, 0, 0) if (unit.startsWith('day')) next.setDate(next.getDate() + value) else if (unit.startsWith('week')) next.setDate(next.getDate() + value * 7) else if (unit.startsWith('mon')) next.setMonth(next.getMonth() + value) else if (unit.startsWith('year')) next.setFullYear(next.getFullYear() + value) return next } function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) { const { authFetch } = useAuth() const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(false) 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 [expandedFuture, setExpandedFuture] = useState({}) const [isCompleting, setIsCompleting] = useState(false) const historyPushedForDetailRef = useRef(false) const historyPushedForPostponeRef = useRef(false) const selectedItemForDetailRef = useRef(null) const selectedItemForPostponeRef = useRef(null) const fetchItems = async () => { if (!purchaseConfigId) return try { setLoading(true) setError(false) const response = await authFetch(`/api/purchase/items/${purchaseConfigId}`) if (response.ok) { const data = await response.json() setItems(Array.isArray(data) ? data : []) } else { setError(true) } } catch (err) { console.error('Error loading purchase items:', err) setError(true) } finally { setLoading(false) } } useEffect(() => { fetchItems() }, [purchaseConfigId]) const handleRefresh = () => { fetchItems() } const handleClose = () => { onNavigate?.('tasks') } const handleCompleteTask = async () => { if (!taskId || isCompleting) return setIsCompleting(true) try { const response = await authFetch(`/api/tasks/${taskId}/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }) if (response.ok) { setToast({ message: 'Задача выполнена', type: 'success' }) setTimeout(() => onNavigate?.('tasks'), 500) } else { const errorData = await response.json().catch(() => ({})) setToast({ message: errorData.error || 'Ошибка выполнения', type: 'error' }) } } catch (err) { setToast({ message: 'Ошибка выполнения', type: 'error' }) } finally { setIsCompleting(false) } } // Синхронизация refs для диалогов useEffect(() => { selectedItemForDetailRef.current = selectedItemForDetail selectedItemForPostponeRef.current = selectedItemForPostpone }, [selectedItemForDetail, selectedItemForPostpone]) // Пуш в историю при открытии модалок и обработка popstate useEffect(() => { if (selectedItemForPostpone && !historyPushedForPostponeRef.current) { window.history.pushState({ modalOpen: true, type: 'purchase-postpone' }, '', window.location.href) historyPushedForPostponeRef.current = true } else if (!selectedItemForPostpone) { historyPushedForPostponeRef.current = false } if (selectedItemForDetail && !historyPushedForDetailRef.current) { window.history.pushState({ modalOpen: true, type: 'purchase-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 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) }) 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 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] })) } const handleCloseDetail = () => { if (historyPushedForDetailRef.current) { window.history.back() } else { setSelectedItemForDetail(null) } } const handlePostponeClose = () => { if (historyPushedForPostponeRef.current) { window.history.back() } else { setSelectedItemForPostpone(null) setPostponeDate('') } } 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 handleDateSelect = (date) => { if (date) { setPostponeDate(formatDateToLocal(date)) } } const handleDayClick = (date) => { if (date) { handlePostponeSubmitWithDate(formatDateToLocal(date)) } } 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 renderItem = (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 (
Ошибка загрузки
Нет товаров