Files
play-life/play-life-web/src/components/Wishlist.jsx
poignatov 101f4e27ed
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m26s
6.23.0: Архивация досок желаний и товаров
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:27:19 +03:00

797 lines
27 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 { useAuth } from './auth/AuthContext'
import BoardSelector from './BoardSelector'
import LoadingError from './LoadingError'
import WishlistDetail from './WishlistDetail'
import { sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
import './Wishlist.css'
const API_URL = '/api/wishlist'
const BOARDS_CACHE_KEY = 'wishlist_boards_cache'
const ITEMS_CACHE_KEY = 'wishlist_items_cache'
const SELECTED_BOARD_KEY = 'wishlist_selected_board_id'
function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoardId = null, boardDeleted = false }) {
const { authFetch } = useAuth()
const [boards, setBoards] = useState([])
// Восстанавливаем выбранную доску из localStorage или используем initialBoardId
const getInitialBoardId = () => {
if (initialBoardId) return initialBoardId
return getSavedBoardId()
}
// Получает сохранённую доску из localStorage
const getSavedBoardId = () => {
try {
const saved = localStorage.getItem(SELECTED_BOARD_KEY)
if (saved) {
const boardId = parseInt(saved, 10)
if (!isNaN(boardId)) return boardId
}
} catch (err) {
console.error('Error loading selected board from cache:', err)
}
return null
}
const [selectedBoardId, setSelectedBoardIdState] = useState(getInitialBoardId)
const [items, setItems] = useState([])
const [completed, setCompleted] = useState([])
const [completedCount, setCompletedCount] = useState(0)
const [loading, setLoading] = useState(true)
const [boardsLoading, setBoardsLoading] = useState(true)
const [error, setError] = useState('')
const [completedExpanded, setCompletedExpanded] = useState(false)
const [completedLoading, setCompletedLoading] = useState(false)
const [selectedItem, setSelectedItem] = useState(null)
const [selectedWishlistForDetail, setSelectedWishlistForDetail] = useState(null)
const [currentWeekData, setCurrentWeekData] = useState(null)
const fetchingRef = useRef(false)
const fetchingCompletedRef = useRef(false)
const initialFetchDoneRef = useRef(false)
const prevIsActiveRef = useRef(isActive)
const selectedBoardIdRef = useRef(getInitialBoardId())
// Обёртка для setSelectedBoardId с сохранением в localStorage
const setSelectedBoardId = (boardId) => {
selectedBoardIdRef.current = boardId
setSelectedBoardIdState(boardId)
try {
if (boardId) {
localStorage.setItem(SELECTED_BOARD_KEY, String(boardId))
} else {
localStorage.removeItem(SELECTED_BOARD_KEY)
}
} catch (err) {
console.error('Error saving selected board to cache:', err)
}
}
// Загрузка досок из кэша
const loadBoardsFromCache = () => {
try {
const cached = localStorage.getItem(BOARDS_CACHE_KEY)
if (cached) {
const data = JSON.parse(cached)
setBoards(data.boards || [])
// Проверяем, что сохранённая доска существует в списке
if (selectedBoardId) {
const boardExists = data.boards?.some(b => b.id === selectedBoardId)
if (!boardExists && data.boards?.length > 0) {
setSelectedBoardId(data.boards[0].id)
}
} else if (data.boards?.length > 0) {
// Пытаемся восстановить из localStorage
const savedBoardId = getSavedBoardId()
if (savedBoardId && data.boards.some(b => b.id === savedBoardId)) {
setSelectedBoardId(savedBoardId)
} else {
setSelectedBoardId(data.boards[0].id)
}
}
return true
}
} catch (err) {
console.error('Error loading boards from cache:', err)
}
return false
}
// Сохранение досок в кэш
const saveBoardsToCache = (boardsData) => {
try {
localStorage.setItem(BOARDS_CACHE_KEY, JSON.stringify({
boards: boardsData,
timestamp: Date.now()
}))
} catch (err) {
console.error('Error saving boards to cache:', err)
}
}
// Загрузка желаний из кэша (по board_id)
const loadItemsFromCache = (boardId) => {
try {
const cached = localStorage.getItem(`${ITEMS_CACHE_KEY}_${boardId}`)
if (cached) {
const data = JSON.parse(cached)
setItems(data.items || [])
setCompletedCount(data.completedCount || 0)
return true
}
} catch (err) {
console.error('Error loading items from cache:', err)
}
return false
}
// Сохранение желаний в кэш
const saveItemsToCache = (boardId, itemsData, count) => {
try {
localStorage.setItem(`${ITEMS_CACHE_KEY}_${boardId}`, JSON.stringify({
items: itemsData,
completedCount: count,
timestamp: Date.now()
}))
} catch (err) {
console.error('Error saving items to cache:', err)
}
}
// Загрузка списка досок
const fetchBoards = async () => {
try {
const response = await authFetch(`${API_URL}/boards`)
if (response.ok) {
const data = await response.json()
setBoards(data || [])
saveBoardsToCache(data || [])
const firstActive = data?.find(b => !b.is_archived) || (data?.length > 0 ? data[0] : null)
// Проверяем, что выбранная доска существует в списке
if (selectedBoardId) {
const boardExists = data?.some(b => b.id === selectedBoardId)
if (!boardExists && firstActive) {
setSelectedBoardId(firstActive.id)
}
} else if (firstActive) {
// Пытаемся восстановить из localStorage
const savedBoardId = getSavedBoardId()
if (savedBoardId && data.some(b => b.id === savedBoardId)) {
setSelectedBoardId(savedBoardId)
} else {
setSelectedBoardId(firstActive.id)
}
}
}
} catch (err) {
console.error('Error fetching boards:', err)
} finally {
setBoardsLoading(false)
}
}
// Загрузка желаний выбранной доски
const fetchItems = async (boardId) => {
if (!boardId || fetchingRef.current) return
fetchingRef.current = true
try {
const hasDataInState = items.length > 0 || completedCount > 0
if (!hasDataInState) {
const cacheLoaded = loadItemsFromCache(boardId)
if (!cacheLoaded) {
setLoading(true)
}
}
const response = await authFetch(`${API_URL}/boards/${boardId}/items`)
if (!response.ok) {
throw new Error('Ошибка при загрузке желаний')
}
const data = await response.json()
const allItems = [...(data.unlocked || []), ...(data.locked || [])]
const count = data.completed_count || 0
// Проверяем, что пользователь не переключился на другую доску пока шёл запрос
if (selectedBoardIdRef.current !== boardId) return
setItems(allItems)
setCompletedCount(count)
saveItemsToCache(boardId, allItems, count)
setError('')
} catch (err) {
if (selectedBoardIdRef.current !== boardId) return
setError(err.message)
if (!loadItemsFromCache(boardId)) {
setItems([])
setCompletedCount(0)
}
} finally {
if (selectedBoardIdRef.current === boardId) {
setLoading(false)
}
fetchingRef.current = false
}
}
// Загрузка завершённых для текущей доски
const fetchCompleted = async (boardId) => {
if (fetchingCompletedRef.current || !boardId) return
fetchingCompletedRef.current = true
try {
setCompletedLoading(true)
// Используем новый API для получения завершённых на доске
const response = await authFetch(`${API_URL}/boards/${boardId}/completed`)
if (!response.ok) {
const errText = await response.text()
const msg = errText || 'Ошибка при загрузке завершённых желаний'
throw new Error(msg)
}
const data = await response.json()
const completedData = Array.isArray(data) ? data : []
// Проверяем, что пользователь не переключился на другую доску пока шёл запрос
if (selectedBoardIdRef.current !== boardId) return
setCompleted(completedData)
} catch (err) {
console.error('Error fetching completed items:', err)
if (selectedBoardIdRef.current === boardId) {
setCompleted([])
}
} finally {
if (selectedBoardIdRef.current === boardId) {
setCompletedLoading(false)
}
fetchingCompletedRef.current = false
}
}
// Загрузка данных текущей недели для сортировки проектов
const fetchCurrentWeek = async () => {
try {
const response = await authFetch('/api/current-week')
if (response.ok) {
const data = await response.json()
// Обрабатываем ответ: приходит массив с одним объектом [{total: ..., projects: [...]}]
if (Array.isArray(data) && data.length > 0) {
setCurrentWeekData(data[0])
} else if (data && typeof data === 'object') {
setCurrentWeekData(data)
}
}
} catch (err) {
console.error('Error loading current week data:', err)
}
}
// Первая инициализация
useEffect(() => {
if (!initialFetchDoneRef.current) {
initialFetchDoneRef.current = true
// Загружаем доски из кэша
const boardsCacheLoaded = loadBoardsFromCache()
if (boardsCacheLoaded) {
setBoardsLoading(false)
}
// Загружаем доски с сервера
fetchBoards()
// Загружаем данные текущей недели для сортировки проектов
fetchCurrentWeek()
}
}, [])
// Загружаем желания при смене доски
useEffect(() => {
if (selectedBoardId) {
// Сбрасываем состояние
setItems([])
setCompletedCount(0)
setCompleted([])
setCompletedExpanded(false)
setLoading(true)
// Пробуем загрузить из кэша
const cacheLoaded = loadItemsFromCache(selectedBoardId)
if (cacheLoaded) {
setLoading(false)
}
// Загружаем свежие данные
fetchItems(selectedBoardId)
}
}, [selectedBoardId])
// Обновление при активации таба
useEffect(() => {
const wasActive = prevIsActiveRef.current
prevIsActiveRef.current = isActive
if (!initialFetchDoneRef.current) return
if (isActive && !wasActive) {
fetchBoards()
if (selectedBoardId) {
fetchItems(selectedBoardId)
}
}
}, [isActive])
// Обновление при refreshTrigger
useEffect(() => {
if (refreshTrigger > 0 && selectedBoardId) {
// Очищаем кэш для текущей доски, чтобы загрузить свежие данные
try {
localStorage.removeItem(`${ITEMS_CACHE_KEY}_${selectedBoardId}`)
} catch (err) {
console.error('Error clearing cache:', err)
}
fetchBoards()
fetchItems(selectedBoardId)
if (completedExpanded) {
fetchCompleted(selectedBoardId)
}
}
}, [refreshTrigger, selectedBoardId])
// Обновление при initialBoardId (когда создана новая доска или переход по ссылке)
useEffect(() => {
if (initialBoardId && initialBoardId !== selectedBoardId) {
// Сбрасываем флаг загрузки, чтобы не блокировать новую загрузку
fetchingRef.current = false
// Обновляем список досок (чтобы новая доска появилась)
fetchBoards().then(() => {
// Переключаемся на новую доску после обновления списка
// Это вызовет useEffect для selectedBoardId, который загрузит данные
setSelectedBoardId(initialBoardId)
})
}
}, [initialBoardId])
// Обработка удаления доски - выбираем первую доступную
useEffect(() => {
if (boardDeleted && boards.length > 0) {
// Очищаем текущие данные
setItems([])
setCompletedCount(0)
setCompleted([])
setCompletedExpanded(false)
setLoading(true)
// Обновляем список досок и выбираем первую
fetchBoards().then(() => {
// fetchBoards обновит boards, но мы уже в этом useEffect
// selectedBoardId обновится автоматически в useEffect ниже
})
}
}, [boardDeleted])
// Если текущая доска больше не существует в списке - выбираем первую неархивную
useEffect(() => {
if (boards.length > 0 && selectedBoardId) {
const boardExists = boards.some(b => b.id === selectedBoardId)
if (!boardExists) {
const firstActive = boards.find(b => !b.is_archived) || boards[0]
setSelectedBoardId(firstActive.id)
}
}
}, [boards, selectedBoardId])
const handleBoardChange = (boardId) => {
setSelectedBoardId(boardId)
}
const handleBoardEdit = (boardId) => {
onNavigate?.('board-form', { boardId: boardId || selectedBoardId })
}
const handleAddBoard = () => {
onNavigate?.('board-form', { boardId: null })
}
const handleToggleCompleted = () => {
const newExpanded = !completedExpanded
setCompletedExpanded(newExpanded)
if (newExpanded && completedCount > 0) {
fetchCompleted(selectedBoardId)
}
}
const handleAddClick = () => {
// Если selectedBoardId равен null, но есть доски, используем первую доску
// Если доски еще не загружены, используем initialBoardId
const boardIdToUse = selectedBoardId || (boards.length > 0 ? boards[0].id : initialBoardId)
onNavigate?.('wishlist-form', { wishlistId: undefined, boardId: boardIdToUse })
}
const handleItemClick = (item) => {
setSelectedWishlistForDetail(item.id)
}
const handleCloseDetail = () => {
setSelectedWishlistForDetail(null)
}
const handleMenuClick = (item, e) => {
e.stopPropagation()
setSelectedItem(item)
}
const handleEdit = () => {
if (selectedItem) {
onNavigate?.('wishlist-form', { wishlistId: selectedItem.id, boardId: selectedBoardId })
setSelectedItem(null)
}
}
const handleDelete = async () => {
if (!selectedItem) return
try {
const response = await authFetch(`${API_URL}/${selectedItem.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Ошибка при удалении')
}
setSelectedItem(null)
await fetchItems(selectedBoardId)
if (completedExpanded) {
await fetchCompleted(selectedBoardId)
}
} catch (err) {
setError(err.message)
setSelectedItem(null)
}
}
const handleCopy = async () => {
if (!selectedItem) return
try {
const response = await authFetch(`${API_URL}/${selectedItem.id}/copy`, {
method: 'POST',
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
throw new Error(errorText || 'Ошибка при копировании')
}
const newItem = await response.json()
setSelectedItem(null)
// Очищаем кэш для текущей доски, чтобы новое желание появилось в списке
if (selectedBoardId) {
try {
localStorage.removeItem(`${ITEMS_CACHE_KEY}_${selectedBoardId}`)
} catch (err) {
console.error('Error clearing cache:', err)
}
}
// Обновляем список
await fetchItems(selectedBoardId)
// Открываем форму редактирования для нового желания
onNavigate?.('wishlist-form', { wishlistId: newItem.id, boardId: selectedBoardId })
} catch (err) {
console.error('Error copying wishlist item:', err)
setError(err.message || 'Ошибка при копировании')
setSelectedItem(null)
}
}
const formatPrice = (price) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(price)
}
const findFirstUnmetCondition = (item) => {
if (!item.unlock_conditions || item.unlock_conditions.length === 0) {
return null
}
for (const condition of item.unlock_conditions) {
let isMet = false
if (condition.type === 'task_completion') {
isMet = condition.task_completed === true
} else if (condition.type === 'project_points') {
const currentPoints = condition.current_points || 0
const requiredPoints = condition.required_points || 0
isMet = currentPoints >= requiredPoints
}
if (!isMet) {
return condition
}
}
return null
}
const renderUnlockCondition = (item) => {
if (item.completed) return null
const condition = findFirstUnmetCondition(item)
if (!condition) return null
let conditionText = ''
if (condition.type === 'task_completion') {
conditionText = condition.task_name || 'Задача'
} else {
const requiredPoints = condition.required_points || 0
const currentPoints = condition.current_points || 0
const remainingPoints = Math.max(0, requiredPoints - currentPoints)
const project = condition.project_name || 'Проект'
// Показываем оставшиеся баллы в формате "33 в Привлекательность"
// Дата начала отсчёта уже учтена в current_points на бэкенде
conditionText = `${Math.round(remainingPoints)} в ${project}`
}
return (
<div className="unlock-condition-wrapper">
<div className="unlock-condition-line">
<div className="unlock-condition">
<svg className="lock-icon" width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
</svg>
<span className="condition-text">{conditionText}</span>
</div>
</div>
</div>
)
}
// Группируем желания по группам
const groupedItems = useMemo(() => {
const groups = {}
const noGroupItems = []
items.forEach(item => {
if (item.group_name && item.group_name.trim()) {
const groupName = item.group_name.trim()
if (!groups[groupName]) {
groups[groupName] = {
groupName: groupName,
items: []
}
}
groups[groupName].items.push(item)
} else {
noGroupItems.push(item)
}
})
// Сортируем группы по алфавиту
const sortedGroups = Object.values(groups).sort((a, b) =>
a.groupName.localeCompare(b.groupName)
)
return { groups: sortedGroups, noGroupItems }
}, [items])
const renderItem = (item) => {
const isFaded = (!item.unlocked && !item.completed) || item.completed
return (
<div
key={item.id}
className={`wishlist-card ${isFaded ? 'faded' : ''}`}
onClick={() => handleItemClick(item)}
>
{item.completed && (
<div className={`card-status-indicator ${item.rejected ? 'card-status-rejected' : 'card-status-completed'}`}>
{item.rejected ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
)}
</div>
)}
<div className="wishlist-card-content">
<button
className="card-menu-button"
onClick={(e) => handleMenuClick(item, e)}
title="Меню"
>
</button>
<div className="card-image">
{item.image_url ? (
<img src={item.image_url} alt={item.name} />
) : (
<div className="placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</div>
)}
</div>
<div className="card-name">{item.name}</div>
{(() => {
const unmetCondition = findFirstUnmetCondition(item)
if (unmetCondition && !item.completed) {
return renderUnlockCondition(item)
}
if (item.price) {
return <div className="card-price">{formatPrice(item.price)}</div>
}
return null
})()}
</div>
</div>
)
}
if (error && items.length === 0 && !boardsLoading && !loading) {
return (
<div className="wishlist">
<BoardSelector
boards={boards}
selectedBoardId={selectedBoardId}
onBoardChange={handleBoardChange}
onBoardEdit={handleBoardEdit}
onAddBoard={handleAddBoard}
loading={boardsLoading}
showBoardAction={false}
/>
<LoadingError onRetry={() => fetchItems(selectedBoardId)} />
</div>
)
}
return (
<div className="wishlist">
{/* Селектор доски */}
<BoardSelector
boards={boards}
selectedBoardId={selectedBoardId}
onBoardChange={handleBoardChange}
onBoardEdit={handleBoardEdit}
onAddBoard={handleAddBoard}
archivedApiUrl="/api/wishlist/boards/archived"
onBoardUnarchived={() => fetchBoards()}
loading={boardsLoading}
showBoardAction={false}
/>
{/* Основной список */}
{boardsLoading && loading ? (
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
) : loading ? (
<div className="wishlist-loading">
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
) : (
<>
{/* Группы проектов */}
{groupedItems.groups.map(group => (
<div key={group.groupName} className="wishlist-project-group">
<div className="wishlist-project-group-title">{group.groupName}</div>
<div className="wishlist-project-group-items">
{group.items.map(renderItem)}
</div>
</div>
))}
{/* Желания без группы */}
{groupedItems.noGroupItems.length > 0 && (
<div className="wishlist-no-project">
<div className="wishlist-grid">
{groupedItems.noGroupItems.map(renderItem)}
</div>
</div>
)}
{/* Завершённые */}
{completedCount > 0 && (
<>
<div className="section-divider">
<button
className="completed-toggle"
onClick={handleToggleCompleted}
>
<span className="completed-toggle-icon">
{completedExpanded ? '▼' : '▶'}
</span>
<span>Завершённые</span>
</button>
</div>
{completedExpanded && (
<>
{completedLoading ? (
<div className="loading-completed">
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
) : (
<div className="wishlist-grid">
{completed.map(renderItem)}
</div>
)}
</>
)}
</>
)}
</>
)}
{/* Модальное окно для действий */}
{selectedItem && (
<div className="wishlist-modal-overlay" onClick={() => setSelectedItem(null)}>
<div className="wishlist-modal" onClick={(e) => e.stopPropagation()}>
<div className="wishlist-modal-header">
<h3>{selectedItem.name}</h3>
</div>
<div className="wishlist-modal-actions">
<button className="wishlist-modal-edit" onClick={handleEdit}>
Редактировать
</button>
<button className="wishlist-modal-copy" onClick={handleCopy}>
Копировать
</button>
<button className="wishlist-modal-delete" onClick={handleDelete}>
Удалить
</button>
</div>
</div>
</div>
)}
{/* Модальное окно для деталей желания */}
{selectedWishlistForDetail && (
<WishlistDetail
wishlistId={selectedWishlistForDetail}
onNavigate={onNavigate}
boardId={selectedBoardId}
refreshTrigger={refreshTrigger}
onRefresh={async () => {
await fetchItems(selectedBoardId)
if (completedExpanded) {
await fetchCompleted(selectedBoardId)
}
}}
onClose={handleCloseDetail}
/>
)}
</div>
)
}
export default Wishlist