Files
play-life/play-life-web/src/components/WishlistDetail.jsx
poignatov 91d4a7337c
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
5.9.0: Статус Отклонено для желаний
2026-03-04 12:04:26 +03:00

795 lines
32 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, 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) {
// Сохраняем флаг перед сбросом
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 (
<div className="wishlist-detail-conditions">
<h3 className="wishlist-detail-section-title">Цели:</h3>
{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 (
<div
key={index}
className={`wishlist-detail-condition ${isMet ? 'met' : 'not-met'} ${condition.type === 'task_completion' && condition.task_id ? 'clickable' : ''}`}
onClick={condition.type === 'task_completion' && condition.task_id ? () => handleConditionTaskClick(condition) : undefined}
style={condition.type === 'task_completion' && condition.task_id ? { cursor: 'pointer' } : {}}
>
<div className="condition-header">
<svg className="condition-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
{isMet ? (
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
) : (
<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>
{/* Показываем дату для целей-задач, если 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 (
<div className="condition-task-date">
{dateText}
</div>
)
})()}
{progress && progress.type === 'points' && !isMet && (
<div className="condition-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress.percentage}%` }}
></div>
</div>
<div className="progress-text">
<span>{Math.round(progress.current)} / {Math.round(progress.required)}</span>
{progress.remaining > 0 && (
<span className="progress-remaining">
Осталось: {Math.round(progress.remaining)}
{condition.weeks_text && ` (${condition.weeks_text})`}
</span>
)}
</div>
</div>
)}
</div>
)
})}
</div>
)
}
const modalContent = (
<div className="wishlist-detail-modal-overlay" onClick={handleClose}>
<div className="wishlist-detail-modal" onClick={(e) => e.stopPropagation()}>
<div className="wishlist-detail-modal-header">
<h2
className="wishlist-detail-title"
onClick={wishlistItem ? handleEdit : undefined}
style={{ cursor: wishlistItem ? 'pointer' : 'default' }}
>
{loadingWishlist ? 'Загрузка...' : error ? 'Ошибка' : wishlistItem ? wishlistItem.name : 'Желание'}
{wishlistItem && (
<svg
className="wishlist-detail-edit-icon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
)}
</h2>
<button onClick={handleClose} className="wishlist-detail-close-button">
</button>
</div>
<div className="wishlist-detail-modal-content">
{loadingWishlist && (
<div className="flex justify-center items-center py-8">
<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>
)}
{error && !loadingWishlist && (
<LoadingError onRetry={fetchWishlistDetail} />
)}
{!loadingWishlist && !error && wishlistItem && (
<>
{/* Изображение */}
{wishlistItem.image_url && (
<div className="wishlist-detail-image">
<img src={wishlistItem.image_url} alt={wishlistItem.name} />
</div>
)}
{/* Цена */}
{wishlistItem.price && (
<div className="wishlist-detail-price">
{formatPrice(wishlistItem.price)}
</div>
)}
{/* Ссылка */}
{wishlistItem.link && (() => {
try {
const url = new URL(wishlistItem.link)
const host = url.host.replace(/^www\./, '') // Убираем www. если есть
return (
<div className="wishlist-detail-link">
<a href={wishlistItem.link} target="_blank" rel="noopener noreferrer">
{host}
</a>
</div>
)
} catch {
// Если URL некорректный, показываем оригинальный текст
return (
<div className="wishlist-detail-link">
<a href={wishlistItem.link} target="_blank" rel="noopener noreferrer">
Открыть ссылку
</a>
</div>
)
}
})()}
{/* Условия разблокировки */}
{renderUnlockConditions()}
{/* Связанная задача или кнопки действий */}
{wishlistItem.unlocked && (
<>
{wishlistItem.linked_task && wishlistItem.linked_task.user_id === user?.id ? (
<div className="wishlist-detail-linked-task">
<div style={{ position: 'relative', display: 'inline-block', width: '100%' }}>
<div
className="task-item"
onClick={handleTaskItemClick}
>
<div className="task-item-content">
<div
className="task-checkmark"
onClick={handleTaskCheckmarkClick}
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">
{wishlistItem.linked_task.name}
</div>
{/* Показываем дату только для выполненных задач (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 (
<div className="task-next-show-date">
{dateText}
</div>
)
})()}
</div>
</div>
{wishlistItem && !wishlistItem.completed && (
<div className="task-actions">
<button
className="task-delete-button"
onClick={handleDeleteTask}
title="Удалить задачу"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
)}
</div>
</div>
{wishlistItem?.tasks_count > 0 && (
<div className="wishlist-detail-tasks-badge">
{wishlistItem.tasks_count}
</div>
)}
</div>
</div>
) : (
<div className="wishlist-detail-actions">
{wishlistItem.completed ? (
<button
onClick={handleUncomplete}
disabled={isCompleting}
className="wishlist-detail-uncomplete-button"
>
{isCompleting ? 'Возобновление...' : 'Возобновить'}
</button>
) : (
<>
<div className="wishlist-detail-action-buttons">
<button
onClick={handleComplete}
disabled={isCompleting}
className="wishlist-detail-complete-button"
>
{isCompleting ? '...' : 'Завершить'}
</button>
<button
onClick={handleReject}
disabled={isCompleting}
className="wishlist-detail-reject-button"
>
{isCompleting ? '...' : 'Отклонить'}
</button>
</div>
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={handleCreateTask}
className="wishlist-detail-create-task-button"
title="Создать задачу"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"></path>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
</button>
{wishlistItem?.tasks_count > 0 && (
<div className="wishlist-detail-tasks-badge">
{wishlistItem.tasks_count}
</div>
)}
</div>
</>
)}
</div>
)}
</>
)}
</>
)}
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
{/* Модальное окно для деталей задачи */}
{selectedTaskForDetail && (
<TaskDetail
taskId={selectedTaskForDetail}
onClose={handleCloseDetail}
onRefresh={() => {
fetchWishlistDetail()
if (onRefresh) onRefresh()
}}
onTaskCompleted={handleTaskCompleted}
onNavigate={onNavigate}
/>
)}
</div>
</div>
)
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent
}
export default WishlistDetail