Files
play-life/play-life-web/src/components/PurchaseScreen.jsx
poignatov 25f193a061
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
6.11.1: Фикс моргания кнопки завершения закупки
2026-03-11 09:08:40 +03:00

640 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
setTimeout(() => onNavigate?.('tasks'), 500)
return
} else {
const errorData = await response.json().catch(() => ({}))
setToast({ message: errorData.error || 'Ошибка выполнения', type: 'error' })
}
} catch (err) {
setToast({ message: 'Ошибка выполнения', type: 'error' })
}
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 (
<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()
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('')
}
}}
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>
)
}
return (
<div className="max-w-2xl mx-auto" style={{ paddingBottom: taskId ? '5rem' : '2.5rem' }}>
<button className="close-x-button" onClick={handleClose}></button>
<h2 className="text-2xl font-semibold text-gray-800 mb-6" style={{ marginTop: '1.25rem' }}>{taskName || 'Закупка'}</h2>
{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>
)}
{error && (
<div className="shopping-empty">
<p>Ошибка загрузки</p>
<button onClick={handleRefresh} style={{ marginTop: '8px', color: 'var(--accent-color)' }}>
Повторить
</button>
</div>
)}
{!loading && !error && 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 => renderItem(item))}
</div>
)}
{hasFuture && isFutureExpanded && (
<div className="task-group completed-tasks">
{group.future.map(item => renderItem(item))}
</div>
)}
</div>
)
})}
{/* Кнопка завершения задачи — фиксированная внизу */}
{!loading && !error && taskId && createPortal(
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
padding: '0.75rem 1rem',
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
background: 'linear-gradient(to top, white 60%, rgba(255,255,255,0))',
zIndex: 1500,
display: 'flex',
justifyContent: 'center',
}}>
<button
onClick={handleCompleteTask}
disabled={isCompleting}
style={{
width: '100%',
maxWidth: '42rem',
padding: '0.875rem',
background: 'linear-gradient(to right, #10b981, #059669)',
color: 'white',
border: 'none',
borderRadius: '0.5rem',
fontSize: '1rem',
fontWeight: 600,
cursor: isCompleting ? 'not-allowed' : 'pointer',
opacity: isCompleting ? 0.6 : 1,
transition: 'all 0.2s',
}}
>
{isCompleting ? 'Выполняется...' : 'Завершить'}
</button>
</div>,
document.body
)}
{/* Модалка выполнения */}
{selectedItemForDetail && (
<ShoppingItemDetail
itemId={selectedItemForDetail}
onClose={handleCloseDetail}
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
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 = (
<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
className="task-postpone-quick-button"
onClick={handleTodayClick}
disabled={isPostponing}
>
Сегодня
</button>
)}
{!isTomorrow && (
<button
className="task-postpone-quick-button"
onClick={handleTomorrowClick}
disabled={isPostponing}
>
Завтра
</button>
)}
{!isCurrentDatePlanned && (
<button
className="task-postpone-quick-button"
onClick={() => handlePostponeSubmitWithDate(plannedDateStr)}
disabled={isPostponing}
>
По плану
</button>
)}
{nextShowAtStr && (
<button
className="task-postpone-quick-button task-postpone-quick-button-danger"
onClick={handleWithoutDateClick}
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 PurchaseScreen