import React, { useState, useEffect, useCallback, useRef } from 'react' import { createPortal } from 'react-dom' import { useAuth } from './auth/AuthContext' import TaskDetail from './TaskDetail' import LoadingError from './LoadingError' import Toast from './Toast' import './WishlistDetail.css' import './TaskList.css' const API_URL = '/api/wishlist' function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId, onClose, previousTab }) { const { authFetch, user } = useAuth() const [wishlistItem, setWishlistItem] = useState(null) const [loading, setLoading] = useState(true) const [loadingWishlist, setLoadingWishlist] = useState(true) const [error, setError] = useState(null) const [isCompleting, setIsCompleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const [toastMessage, setToastMessage] = useState(null) const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null) const fetchWishlistDetail = useCallback(async () => { try { setLoadingWishlist(true) setLoading(true) setError(null) const response = await authFetch(`${API_URL}/${wishlistId}`) if (!response.ok) { throw new Error('Ошибка загрузки желания') } const data = await response.json() setWishlistItem(data) } catch (err) { setError(err.message) console.error('Error fetching wishlist detail:', err) } finally { setLoading(false) setLoadingWishlist(false) } }, [wishlistId, authFetch]) useEffect(() => { if (wishlistId) { fetchWishlistDetail() } else { setWishlistItem(null) setLoading(true) setLoadingWishlist(true) setError(null) } }, [wishlistId, fetchWishlistDetail]) const handleEdit = () => { // Сбрасываем флаг, чтобы handleClose не вызвал history.back() // handleTabChange заменит запись модального окна через replaceState historyPushedForWishlistRef.current = false onClose?.() onNavigate?.('wishlist-form', { wishlistId: wishlistId, boardId: boardId }) } const handleComplete = async () => { if (!wishlistItem || !wishlistItem.unlocked) return setIsCompleting(true) try { const response = await authFetch(`${API_URL}/${wishlistId}/complete`, { method: 'POST', }) if (!response.ok) { throw new Error('Ошибка при завершении') } if (onRefresh) { onRefresh() } if (onNavigate) { onNavigate('wishlist') } onClose?.() } catch (err) { console.error('Error completing wishlist:', err) setToastMessage({ text: err.message || 'Ошибка при завершении', type: 'error' }) } finally { setIsCompleting(false) } } const handleReject = async () => { if (!wishlistItem || !wishlistItem.unlocked) return setIsCompleting(true) try { const response = await authFetch(`${API_URL}/${wishlistId}/reject`, { method: 'POST', }) if (!response.ok) { throw new Error('Ошибка при отклонении') } if (onRefresh) { onRefresh() } if (onNavigate) { onNavigate('wishlist') } onClose?.() } catch (err) { console.error('Error rejecting wishlist:', err) setToastMessage({ text: err.message || 'Ошибка при отклонении', type: 'error' }) } finally { setIsCompleting(false) } } const handleUncomplete = async () => { if (!wishlistItem || !wishlistItem.completed) return setIsCompleting(true) try { const response = await authFetch(`${API_URL}/${wishlistId}/uncomplete`, { method: 'POST', }) if (!response.ok) { throw new Error('Ошибка при возобновлении желания') } if (onRefresh) { onRefresh() } fetchWishlistDetail() onClose?.() } catch (err) { console.error('Error uncompleting wishlist:', err) setToastMessage({ text: err.message || 'Ошибка при возобновлении желания', type: 'error' }) } finally { setIsCompleting(false) } } const handleDelete = async () => { if (!wishlistItem) return if (!window.confirm('Вы уверены, что хотите удалить это желание?')) { return } setIsDeleting(true) try { const response = await authFetch(`${API_URL}/${wishlistId}`, { method: 'DELETE', }) if (!response.ok) { throw new Error('Ошибка при удалении') } if (onRefresh) { onRefresh() } if (onNavigate) { onNavigate('wishlist') } } catch (err) { console.error('Error deleting wishlist:', err) setToastMessage({ text: err.message || 'Ошибка при удалении', type: 'error' }) } finally { setIsDeleting(false) } } const handleCreateTask = () => { if (!wishlistItem || !wishlistItem.unlocked || wishlistItem.completed) return onNavigate?.('task-form', { wishlistId: wishlistId }) } const handleTaskCheckmarkClick = (e) => { e.stopPropagation() if (wishlistItem?.linked_task) { setSelectedTaskForDetail(wishlistItem.linked_task.id) } } const handleTaskItemClick = () => { if (wishlistItem?.linked_task) { setSelectedTaskForDetail(wishlistItem.linked_task.id) } } const handleConditionTaskClick = async (condition) => { if (condition.type !== 'task_completion' || !condition.task_id) { return } try { // Загружаем информацию о задаче const response = await authFetch(`/api/tasks/${condition.task_id}`) if (!response.ok) { throw new Error('Ошибка при загрузке задачи') } const taskDetail = await response.json() // Проверяем, является ли задача тестом const isTest = taskDetail.task?.config_id != null if (isTest) { // Для задач-тестов открываем экран прохождения теста if (taskDetail.task.config_id) { onNavigate?.('test', { configId: taskDetail.task.config_id, taskId: taskDetail.task.id, maxCards: taskDetail.max_cards }) } } else { // Для обычных задач открываем модальное окно выполнения setSelectedTaskForDetail(condition.task_id) } } catch (err) { console.error('Failed to load task details:', err) setToastMessage({ text: 'Ошибка при загрузке задачи', type: 'error' }) } } const handleCloseDetail = (skipHistoryBack = false) => { // Если skipHistoryBack = true (например, при навигации на форму редактирования), // закрываем модальные окна без удаления записей из истории // App.jsx сам обработает навигацию и заменит запись task-detail на task-form через replaceState // Запись wishlist-detail останется в истории, но экран будет закрыт if (skipHistoryBack === true) { // Сохраняем флаг перед сбросом const hadWishlistHistory = historyPushedForWishlistRef.current // Закрываем модальные окна historyPushedForTaskRef.current = false setSelectedTaskForDetail(null) historyPushedForWishlistRef.current = false // Закрываем экран желания через onClose // Навигация на task-form уже происходит в TaskDetail, поэтому не вызываем onNavigate здесь // App.jsx обработает навигацию и заменит запись task-detail на task-form через replaceState if (hadWishlistHistory && onClose) { onClose() } } else if (historyPushedForTaskRef.current) { window.history.back() } else { historyPushedForTaskRef.current = false setSelectedTaskForDetail(null) } } // Добавляем запись в историю при открытии модальных окон и обрабатываем "назад" const historyPushedForWishlistRef = useRef(false) const historyPushedForTaskRef = useRef(false) const wishlistIdRef = useRef(wishlistId) const selectedTaskForDetailRef = useRef(selectedTaskForDetail) // Обновляем refs при изменении значений useEffect(() => { wishlistIdRef.current = wishlistId selectedTaskForDetailRef.current = selectedTaskForDetail }, [wishlistId, selectedTaskForDetail]) useEffect(() => { if (wishlistId && !historyPushedForWishlistRef.current) { // Добавляем запись в историю при открытии модального окна WishlistDetail window.history.pushState({ modalOpen: true, type: 'wishlist-detail' }, '', window.location.href) historyPushedForWishlistRef.current = true } else if (!wishlistId) { historyPushedForWishlistRef.current = false } if (selectedTaskForDetail && !historyPushedForTaskRef.current) { // Добавляем запись в историю при открытии вложенного модального окна TaskDetail window.history.pushState({ modalOpen: true, type: 'task-detail', nested: true }, '', window.location.href) historyPushedForTaskRef.current = true } else if (!selectedTaskForDetail) { historyPushedForTaskRef.current = false } if (!wishlistId && !selectedTaskForDetail) return const handlePopState = (event) => { // Проверяем наличие модальных окон в DOM const taskDetailModal = document.querySelector('.task-detail-modal-overlay') const wishlistDetailModal = document.querySelector('.wishlist-detail-modal-overlay') // Используем refs для получения актуального состояния const currentTaskDetail = selectedTaskForDetailRef.current const currentWishlistId = wishlistIdRef.current // Сначала проверяем вложенное модальное окно TaskDetail if (currentTaskDetail || taskDetailModal) { setSelectedTaskForDetail(null) historyPushedForTaskRef.current = false // Возвращаем запись для WishlistDetail if (currentWishlistId || wishlistDetailModal) { window.history.pushState({ modalOpen: true, type: 'wishlist-detail' }, '', window.location.href) } return } // Если открыто модальное окно WishlistDetail, закрываем его if (currentWishlistId || wishlistDetailModal) { if (onClose) { onClose() } else { // Возвращаемся на предыдущий таб, если он был сохранен, иначе на wishlist if (previousTab) { if (boardId) { onNavigate?.(previousTab, { boardId }) } else { onNavigate?.(previousTab) } } else if (boardId) { onNavigate?.('wishlist', { boardId }) } else { onNavigate?.('wishlist') } } historyPushedForWishlistRef.current = false // Следующее нажатие "назад" обработается App.jsx нормально return } } window.addEventListener('popstate', handlePopState) return () => { window.removeEventListener('popstate', handlePopState) } }, [wishlistId, selectedTaskForDetail, onClose, onNavigate, previousTab, boardId]) const handleClose = () => { // Если была добавлена запись в историю, удаляем её через history.back() // Обработчик popstate закроет модальное окно if (historyPushedForWishlistRef.current) { window.history.back() } else if (onClose) { onClose() } else { // Возвращаемся на предыдущий таб, если он был сохранен, иначе на wishlist if (previousTab) { // Сохраняем boardId при возврате на предыдущий таб if (boardId) { onNavigate?.(previousTab, { boardId }) } else { onNavigate?.(previousTab) } } else if (boardId) { onNavigate?.('wishlist', { boardId }) } else { onNavigate?.('wishlist') } } } const handleTaskCompleted = () => { setToastMessage({ text: 'Задача выполнена', type: 'success' }) // После выполнения задачи желание тоже завершается, перенаправляем на список if (onRefresh) { onRefresh() } if (onNavigate) { onNavigate('wishlist') } } const handleDeleteTask = async (e) => { e.stopPropagation() if (!wishlistItem?.linked_task || wishlistItem?.completed) return if (!window.confirm('Удалить задачу, связанную с желанием?')) { return } try { // Удаляем задачу (помечаем как удалённую) const deleteResponse = await authFetch(`/api/tasks/${wishlistItem.linked_task.id}`, { method: 'DELETE', }) if (!deleteResponse.ok) { const errorData = await deleteResponse.json().catch(() => ({})) throw new Error(errorData.message || errorData.error || 'Ошибка при удалении задачи') } setToastMessage({ text: 'Задача удалена', type: 'success' }) // Обновляем данные желания fetchWishlistDetail() if (onRefresh) { onRefresh() } } catch (err) { console.error('Error deleting task:', err) setToastMessage({ text: err.message || 'Ошибка при удалении задачи', type: 'error' }) } } const formatPrice = (price) => { return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(price) } const renderUnlockConditions = () => { if (!wishlistItem || !wishlistItem.unlock_conditions || wishlistItem.unlock_conditions.length === 0) { return null } return (

Цели:

{wishlistItem.unlock_conditions.map((condition, index) => { let conditionText = '' let progress = null if (condition.type === 'task_completion') { conditionText = condition.task_name || 'Задача' const isCompleted = condition.task_completed === true progress = { type: 'task', completed: isCompleted } } else { const requiredPoints = condition.required_points || 0 const currentPoints = condition.current_points || 0 const project = condition.project_name || 'Проект' let dateText = '' if (condition.start_date) { const date = new Date(condition.start_date + 'T00:00:00') dateText = ` с ${date.toLocaleDateString('ru-RU')}` } else { dateText = ' за всё время' } conditionText = `${requiredPoints} в ${project}${dateText}` const remaining = Math.max(0, requiredPoints - currentPoints) progress = { type: 'points', current: currentPoints, required: requiredPoints, remaining: remaining, percentage: requiredPoints > 0 ? Math.min(100, (currentPoints / requiredPoints) * 100) : 0 } } // Проверяем каждое условие индивидуально let isMet = false if (progress?.type === 'task') { isMet = progress.completed === true } else if (progress?.type === 'points') { isMet = progress.current >= progress.required } return (
handleConditionTaskClick(condition) : undefined} style={condition.type === 'task_completion' && condition.task_id ? { cursor: 'pointer' } : {}} >
{isMet ? ( ) : ( )} {conditionText}
{/* Показываем дату для целей-задач, если next_show_at > сегодня */} {condition.type === 'task_completion' && condition.task_next_show_at && (() => { const showDate = new Date(condition.task_next_show_at) // Нормализуем дату: устанавливаем время в 00:00:00 в локальном времени const showDateNormalized = new Date(showDate.getFullYear(), showDate.getMonth(), showDate.getDate()) const today = new Date() const todayNormalized = new Date(today.getFullYear(), today.getMonth(), today.getDate()) // Показываем только если дата > сегодня if (showDateNormalized.getTime() <= todayNormalized.getTime()) { return null } const tomorrowNormalized = new Date(todayNormalized) tomorrowNormalized.setDate(tomorrowNormalized.getDate() + 1) let dateText if (showDateNormalized.getTime() === tomorrowNormalized.getTime()) { dateText = 'Завтра' } else { dateText = showDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }) } return (
{dateText}
) })()} {progress && progress.type === 'points' && !isMet && (
{Math.round(progress.current)} / {Math.round(progress.required)} {progress.remaining > 0 && ( Осталось: {Math.round(progress.remaining)} {condition.weeks_text && ` (${condition.weeks_text})`} )}
)}
) })}
) } const modalContent = (
e.stopPropagation()}>

{loadingWishlist ? 'Загрузка...' : error ? 'Ошибка' : wishlistItem ? wishlistItem.name : 'Желание'} {wishlistItem && ( )}

{loadingWishlist && (
Загрузка...
)} {error && !loadingWishlist && ( )} {!loadingWishlist && !error && wishlistItem && ( <> {/* Изображение */} {wishlistItem.image_url && (
{wishlistItem.name}
)} {/* Цена */} {wishlistItem.price && (
{formatPrice(wishlistItem.price)}
)} {/* Ссылка */} {wishlistItem.link && (() => { try { const url = new URL(wishlistItem.link) const host = url.host.replace(/^www\./, '') // Убираем www. если есть return (
{host}
) } catch { // Если URL некорректный, показываем оригинальный текст return (
Открыть ссылку
) } })()} {/* Условия разблокировки */} {renderUnlockConditions()} {/* Связанная задача или кнопки действий */} {wishlistItem.unlocked && ( <> {wishlistItem.linked_task && wishlistItem.linked_task.user_id === user?.id ? (
{wishlistItem.linked_task.name}
{/* Показываем дату только для выполненных задач (next_show_at > сегодня) */} {wishlistItem.linked_task.next_show_at && (() => { const showDate = new Date(wishlistItem.linked_task.next_show_at) // Нормализуем дату: устанавливаем время в 00:00:00 в локальном времени const showDateNormalized = new Date(showDate.getFullYear(), showDate.getMonth(), showDate.getDate()) const today = new Date() const todayNormalized = new Date(today.getFullYear(), today.getMonth(), today.getDate()) // Показываем только если дата > сегодня if (showDateNormalized.getTime() <= todayNormalized.getTime()) { return null } const tomorrowNormalized = new Date(todayNormalized) tomorrowNormalized.setDate(tomorrowNormalized.getDate() + 1) let dateText if (showDateNormalized.getTime() === tomorrowNormalized.getTime()) { dateText = 'Завтра' } else { dateText = showDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }) } return (
{dateText}
) })()}
{wishlistItem && !wishlistItem.completed && (
)}
{wishlistItem?.tasks_count > 0 && (
{wishlistItem.tasks_count}
)}
) : (
{wishlistItem.completed ? ( ) : ( <>
{wishlistItem?.tasks_count > 0 && (
{wishlistItem.tasks_count}
)}
)}
)} )} )}
{toastMessage && ( setToastMessage(null)} /> )} {/* Модальное окно для деталей задачи */} {selectedTaskForDetail && ( { fetchWishlistDetail() if (onRefresh) onRefresh() }} onTaskCompleted={handleTaskCompleted} onNavigate={onNavigate} /> )}
) return typeof document !== 'undefined' ? createPortal(modalContent, document.body) : modalContent } export default WishlistDetail