Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m19s
1696 lines
61 KiB
JavaScript
1696 lines
61 KiB
JavaScript
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
import Cropper from 'react-easy-crop'
|
||
import { useAuth } from './auth/AuthContext'
|
||
import Toast from './Toast'
|
||
import './WishlistForm.css'
|
||
|
||
// Извлекает первый URL из текста
|
||
function extractUrl(text) {
|
||
if (!text) return ''
|
||
const match = text.match(/https?:\/\/[^\s<>"'`,;!)\]]+/i)
|
||
return match ? match[0] : text
|
||
}
|
||
|
||
const API_URL = '/api/wishlist'
|
||
const TASKS_API_URL = '/api/tasks'
|
||
const PROJECTS_API_URL = '/projects'
|
||
const WISHLIST_FORM_STATE_KEY = 'wishlistFormPendingState'
|
||
|
||
function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId: newTaskIdProp, boardId, isActive }) {
|
||
const { authFetch, user } = useAuth()
|
||
|
||
// newTaskId может прийти из props (через onNavigate) или из sessionStorage (через history.back)
|
||
const [newTaskId] = useState(() => {
|
||
if (newTaskIdProp) return newTaskIdProp
|
||
const stored = sessionStorage.getItem('wishlistFormNewTaskId')
|
||
if (stored) {
|
||
sessionStorage.removeItem('wishlistFormNewTaskId')
|
||
return parseInt(stored, 10)
|
||
}
|
||
return undefined
|
||
})
|
||
|
||
const [name, setName] = useState('')
|
||
const [price, setPrice] = useState('')
|
||
const [link, setLink] = useState('')
|
||
const [imageUrl, setImageUrl] = useState(null)
|
||
const [imageFile, setImageFile] = useState(null)
|
||
const [imageRemoved, setImageRemoved] = useState(false) // Флаг удаления фото
|
||
const [showCropper, setShowCropper] = useState(false)
|
||
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||
const [zoom, setZoom] = useState(1)
|
||
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null)
|
||
const [unlockConditions, setUnlockConditions] = useState([])
|
||
const [showConditionForm, setShowConditionForm] = useState(false)
|
||
const [editingConditionIndex, setEditingConditionIndex] = useState(null)
|
||
const [tasks, setTasks] = useState([])
|
||
const [projects, setProjects] = useState([])
|
||
const [groupName, setGroupName] = useState('')
|
||
const [groupSuggestions, setGroupSuggestions] = useState([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [toastMessage, setToastMessage] = useState(null)
|
||
const [loadingWishlist, setLoadingWishlist] = useState(false)
|
||
const [fetchingMetadata, setFetchingMetadata] = useState(false)
|
||
const [restoredFromSession, setRestoredFromSession] = useState(() => {
|
||
// Инициализируем флаг сразу, чтобы loadWishlist не запустился до восстановления
|
||
return !!(newTaskId && sessionStorage.getItem(WISHLIST_FORM_STATE_KEY))
|
||
})
|
||
const [loadedWishlistData, setLoadedWishlistData] = useState(null) // Данные желания для последующего маппинга условий
|
||
const [imageUrlInput, setImageUrlInput] = useState('') // Ссылка на картинку для загрузки по URL
|
||
const [loadingImageFromUrl, setLoadingImageFromUrl] = useState(false)
|
||
const [newTaskConsumed, setNewTaskConsumed] = useState(false) // Флаг что newTaskId уже добавлен как цель
|
||
const fileInputRef = useRef(null)
|
||
|
||
// Загрузка задач, проектов и саджестов групп
|
||
useEffect(() => {
|
||
const loadData = async () => {
|
||
try {
|
||
// Загружаем задачи
|
||
const tasksResponse = await authFetch(TASKS_API_URL)
|
||
if (tasksResponse.ok) {
|
||
const tasksData = await tasksResponse.json()
|
||
setTasks(Array.isArray(tasksData) ? tasksData : [])
|
||
}
|
||
|
||
// Загружаем проекты (нужны для ConditionForm)
|
||
const projectsResponse = await authFetch(PROJECTS_API_URL)
|
||
if (projectsResponse.ok) {
|
||
const projectsData = await projectsResponse.json()
|
||
setProjects(Array.isArray(projectsData) ? projectsData : [])
|
||
}
|
||
|
||
// Загружаем саджесты групп
|
||
const groupsResponse = await authFetch('/api/group-suggestions')
|
||
if (groupsResponse.ok) {
|
||
const groupsData = await groupsResponse.json()
|
||
setGroupSuggestions(Array.isArray(groupsData) ? groupsData : [])
|
||
}
|
||
} catch (err) {
|
||
console.error('Error loading data:', err)
|
||
}
|
||
}
|
||
loadData()
|
||
}, [authFetch])
|
||
|
||
// Загрузка желания при редактировании или сброс формы при создании
|
||
useEffect(() => {
|
||
// Пропускаем загрузку, если состояние было восстановлено из sessionStorage
|
||
if (restoredFromSession) {
|
||
console.log('[WishlistForm] Skipping loadWishlist - restored from session')
|
||
return
|
||
}
|
||
|
||
if (wishlistId !== undefined && wishlistId !== null) {
|
||
// Загружаем желание независимо от наличия задач и проектов
|
||
loadWishlist()
|
||
} else if (wishlistId === undefined || wishlistId === null) {
|
||
// Сбрасываем форму при создании новой задачи
|
||
resetForm()
|
||
setLoadedWishlistData(null)
|
||
}
|
||
}, [wishlistId, restoredFromSession])
|
||
|
||
// Обновляем маппинг условий после загрузки задач и проектов
|
||
useEffect(() => {
|
||
// Если есть загруженные данные желания, но маппинг еще не выполнен,
|
||
// обновляем условия с правильным маппингом
|
||
if (loadedWishlistData && tasks.length > 0 && projects.length > 0) {
|
||
const data = loadedWishlistData
|
||
setName(data.name || '')
|
||
setPrice(data.price ? String(data.price) : '')
|
||
setLink(data.link || '')
|
||
setImageUrl(data.image_url || null)
|
||
setGroupName(data.group_name || '')
|
||
if (data.unlock_conditions) {
|
||
setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({
|
||
id: cond.id || null,
|
||
type: cond.type,
|
||
task_id: cond.type === 'task_completion' ? (cond.task_id || tasks.find(t => t.name === cond.task_name)?.id) : null,
|
||
task_name: cond.task_name || null,
|
||
project_id: cond.type === 'project_points' ? (cond.project_id || projects.find(p => p.project_name === cond.project_name)?.project_id) : null,
|
||
project_name: cond.project_name || null,
|
||
required_points: cond.required_points || null,
|
||
start_date: cond.start_date || null,
|
||
display_order: idx,
|
||
user_id: cond.user_id || null,
|
||
weeks_text: cond.weeks_text || null,
|
||
})))
|
||
} else {
|
||
setUnlockConditions([])
|
||
}
|
||
setLoadedWishlistData(null) // Очищаем после применения
|
||
}
|
||
}, [tasks, projects, loadedWishlistData])
|
||
|
||
// Сброс формы при размонтировании компонента или при изменении wishlistId на undefined
|
||
useEffect(() => {
|
||
return () => {
|
||
resetForm()
|
||
}
|
||
}, [wishlistId])
|
||
|
||
// Открываем форму редактирования условия, если передан editConditionIndex
|
||
useEffect(() => {
|
||
if (editConditionIndex !== undefined && editConditionIndex !== null && unlockConditions.length > editConditionIndex) {
|
||
setEditingConditionIndex(editConditionIndex)
|
||
setShowConditionForm(true)
|
||
} else if (editConditionIndex === undefined || editConditionIndex === null) {
|
||
// Закрываем форму условия, если editConditionIndex сброшен
|
||
setEditingConditionIndex(null)
|
||
setShowConditionForm(false)
|
||
}
|
||
}, [editConditionIndex, unlockConditions])
|
||
|
||
// Обработка кнопки "назад" для диалога ConditionForm
|
||
const showConditionFormRef = useRef(false)
|
||
const conditionClosedByPopStateRef = useRef(false)
|
||
showConditionFormRef.current = showConditionForm
|
||
|
||
useEffect(() => {
|
||
const handlePopState = () => {
|
||
if (showConditionFormRef.current) {
|
||
// Закрываем диалог — popstate уже убрал запись из стека
|
||
conditionClosedByPopStateRef.current = true
|
||
setShowConditionForm(false)
|
||
setEditingConditionIndex(null)
|
||
}
|
||
}
|
||
window.addEventListener('popstate', handlePopState)
|
||
return () => window.removeEventListener('popstate', handlePopState)
|
||
}, [])
|
||
|
||
// Восстановление состояния при возврате с создания задачи
|
||
useEffect(() => {
|
||
const savedState = sessionStorage.getItem(WISHLIST_FORM_STATE_KEY)
|
||
console.log('[WishlistForm] Checking restore - newTaskId:', newTaskId, 'savedState exists:', !!savedState)
|
||
|
||
if (savedState && newTaskId) {
|
||
console.log('[WishlistForm] Starting restoration...')
|
||
try {
|
||
const state = JSON.parse(savedState)
|
||
console.log('[WishlistForm] Parsed state:', state)
|
||
|
||
// Восстанавливаем состояние формы
|
||
setName(state.name || '')
|
||
setPrice(state.price || '')
|
||
setLink(state.link || '')
|
||
setImageUrl(state.imageUrl || null)
|
||
setImageRemoved(false) // Сбрасываем флаг удаления при восстановлении
|
||
|
||
// Восстанавливаем условия и автоматически добавляем новую задачу
|
||
const restoredConditions = state.unlockConditions || []
|
||
console.log('[WishlistForm] Restored conditions:', restoredConditions)
|
||
|
||
// Устанавливаем флаг синхронно, чтобы loadWishlist не перезаписал восстановленное состояние
|
||
setRestoredFromSession(true)
|
||
|
||
// Перезагружаем задачи, чтобы новая задача была в списке
|
||
const reloadTasks = async () => {
|
||
console.log('[WishlistForm] Reloading tasks...')
|
||
try {
|
||
const tasksResponse = await authFetch(TASKS_API_URL)
|
||
console.log('[WishlistForm] Tasks response ok:', tasksResponse.ok)
|
||
if (tasksResponse.ok) {
|
||
const tasksData = await tasksResponse.json()
|
||
console.log('[WishlistForm] Tasks loaded:', tasksData.length)
|
||
setTasks(Array.isArray(tasksData) ? tasksData : [])
|
||
|
||
// Автоматически добавляем цель с новой задачей
|
||
console.log('[WishlistForm] pendingConditionType:', state.pendingConditionType)
|
||
if (state.pendingConditionType === 'task_completion') {
|
||
const newCondition = {
|
||
type: 'task_completion',
|
||
task_id: newTaskId,
|
||
project_id: null,
|
||
required_points: null,
|
||
start_date: null,
|
||
display_order: restoredConditions.length,
|
||
}
|
||
console.log('[WishlistForm] New condition to add:', newCondition)
|
||
|
||
// Если редактировали существующее условие, заменяем его
|
||
if (state.editingConditionIndex !== null && state.editingConditionIndex !== undefined) {
|
||
console.log('[WishlistForm] Replacing existing condition at index:', state.editingConditionIndex)
|
||
const updatedConditions = restoredConditions.map((cond, idx) =>
|
||
idx === state.editingConditionIndex ? { ...newCondition, display_order: idx } : cond
|
||
)
|
||
setUnlockConditions(updatedConditions)
|
||
console.log('[WishlistForm] Updated conditions:', updatedConditions)
|
||
} else {
|
||
// Добавляем новое условие
|
||
const finalConditions = [...restoredConditions, newCondition]
|
||
console.log('[WishlistForm] Adding new condition, final conditions:', finalConditions)
|
||
setUnlockConditions(finalConditions)
|
||
}
|
||
setNewTaskConsumed(true)
|
||
} else {
|
||
setUnlockConditions(restoredConditions)
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[WishlistForm] Error reloading tasks:', err)
|
||
setUnlockConditions(restoredConditions)
|
||
}
|
||
}
|
||
reloadTasks()
|
||
|
||
// Очищаем sessionStorage
|
||
sessionStorage.removeItem(WISHLIST_FORM_STATE_KEY)
|
||
console.log('[WishlistForm] SessionStorage cleared')
|
||
} catch (e) {
|
||
console.error('[WishlistForm] Error restoring wishlist form state:', e)
|
||
sessionStorage.removeItem(WISHLIST_FORM_STATE_KEY)
|
||
}
|
||
}
|
||
}, [newTaskId, authFetch])
|
||
|
||
const loadWishlist = async () => {
|
||
setLoadingWishlist(true)
|
||
try {
|
||
// Сначала очищаем форму, чтобы удалить старые данные
|
||
setName('')
|
||
setPrice('')
|
||
setLink('')
|
||
setImageUrl(null)
|
||
setImageFile(null)
|
||
setImageRemoved(false)
|
||
setUnlockConditions([])
|
||
setError('')
|
||
setShowCropper(false)
|
||
setCrop({ x: 0, y: 0 })
|
||
setZoom(1)
|
||
setCroppedAreaPixels(null)
|
||
setImageUrlInput('')
|
||
setLoadingImageFromUrl(false)
|
||
setShowConditionForm(false)
|
||
setEditingConditionIndex(null)
|
||
setToastMessage(null)
|
||
|
||
const response = await authFetch(`${API_URL}/${wishlistId}`)
|
||
if (!response.ok) {
|
||
throw new Error('Ошибка загрузки желания')
|
||
}
|
||
const data = await response.json()
|
||
|
||
// Если задачи и проекты уже загружены, применяем данные сразу
|
||
// Иначе сохраняем данные для последующего применения
|
||
if (tasks.length > 0 && projects.length > 0) {
|
||
setName(data.name || '')
|
||
setPrice(data.price ? String(data.price) : '')
|
||
setLink(data.link || '')
|
||
setImageUrl(data.image_url || null)
|
||
setImageFile(null) // Сбрасываем imageFile при загрузке существующего желания
|
||
setImageRemoved(false) // Сбрасываем флаг удаления при загрузке
|
||
setGroupName(data.group_name || '')
|
||
if (data.unlock_conditions) {
|
||
setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({
|
||
id: cond.id || null,
|
||
type: cond.type,
|
||
task_id: cond.type === 'task_completion' ? (cond.task_id || tasks.find(t => t.name === cond.task_name)?.id) : null,
|
||
task_name: cond.task_name || null,
|
||
project_id: cond.type === 'project_points' ? (cond.project_id || projects.find(p => p.project_name === cond.project_name)?.project_id) : null,
|
||
project_name: cond.project_name || null,
|
||
required_points: cond.required_points || null,
|
||
start_date: cond.start_date || null,
|
||
display_order: idx,
|
||
user_id: cond.user_id || null,
|
||
weeks_text: cond.weeks_text || null,
|
||
})))
|
||
} else {
|
||
setUnlockConditions([])
|
||
}
|
||
} else {
|
||
// Сохраняем данные для последующего применения после загрузки задач и проектов
|
||
setLoadedWishlistData(data)
|
||
// Применяем базовые данные сразу
|
||
setName(data.name || '')
|
||
setPrice(data.price ? String(data.price) : '')
|
||
setLink(data.link || '')
|
||
setImageUrl(data.image_url || null)
|
||
setImageFile(null)
|
||
setImageRemoved(false) // Сбрасываем флаг удаления при загрузке
|
||
setGroupName(data.group_name || '')
|
||
}
|
||
} catch (err) {
|
||
setError(err.message)
|
||
} finally {
|
||
setLoadingWishlist(false)
|
||
}
|
||
}
|
||
|
||
const resetForm = () => {
|
||
setName('')
|
||
setPrice('')
|
||
setLink('')
|
||
setImageUrl(null)
|
||
setImageFile(null)
|
||
setImageRemoved(false)
|
||
setImageUrlInput('')
|
||
setLoadingImageFromUrl(false)
|
||
setUnlockConditions([])
|
||
setGroupName('')
|
||
setError('')
|
||
setShowCropper(false)
|
||
setCrop({ x: 0, y: 0 })
|
||
setZoom(1)
|
||
setCroppedAreaPixels(null)
|
||
setShowConditionForm(false)
|
||
setEditingConditionIndex(null)
|
||
setToastMessage(null)
|
||
}
|
||
|
||
// Функция для извлечения метаданных из ссылки (по нажатию кнопки)
|
||
const fetchLinkMetadata = useCallback(async () => {
|
||
if (!link || !link.trim()) {
|
||
setToastMessage({ text: 'Введите ссылку', type: 'error' })
|
||
return
|
||
}
|
||
|
||
const extracted = extractUrl(link)
|
||
setLink(extracted)
|
||
|
||
// Проверяем валидность URL
|
||
try {
|
||
new URL(extracted)
|
||
} catch {
|
||
setToastMessage({ text: 'Некорректная ссылка', type: 'error' })
|
||
return
|
||
}
|
||
|
||
setFetchingMetadata(true)
|
||
try {
|
||
const response = await authFetch(`${API_URL}/metadata`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ url: extracted.trim() }),
|
||
})
|
||
|
||
if (response.ok) {
|
||
const metadata = await response.json()
|
||
let loaded = false
|
||
|
||
// Заполняем название только если поле пустое
|
||
if (metadata.title && !name) {
|
||
setName(metadata.title)
|
||
loaded = true
|
||
}
|
||
|
||
// Заполняем цену только если поле пустое
|
||
if (metadata.price && !price) {
|
||
setPrice(String(metadata.price))
|
||
loaded = true
|
||
}
|
||
|
||
// Загружаем изображение только если нет текущего
|
||
if (metadata.image && !imageUrl) {
|
||
try {
|
||
// Загружаем изображение через бэкенд прокси для обхода CORS
|
||
const proxyUrl = `${API_URL}/proxy-image?url=${encodeURIComponent(metadata.image)}`
|
||
const imgResponse = await authFetch(proxyUrl)
|
||
if (imgResponse.ok) {
|
||
const blob = await imgResponse.blob()
|
||
// Проверяем размер (максимум 5MB)
|
||
if (blob.size <= 5 * 1024 * 1024 && blob.type.startsWith('image/')) {
|
||
const reader = new FileReader()
|
||
reader.onload = () => {
|
||
setImageUrl(reader.result)
|
||
setImageFile(blob)
|
||
setImageRemoved(false) // Сбрасываем флаг удаления при загрузке из метаданных
|
||
setShowCropper(true)
|
||
}
|
||
reader.readAsDataURL(blob)
|
||
loaded = true
|
||
}
|
||
}
|
||
} catch (imgErr) {
|
||
console.error('Error loading image from URL:', imgErr)
|
||
}
|
||
}
|
||
|
||
if (loaded) {
|
||
setToastMessage({ text: 'Информация загружена из ссылки', type: 'success' })
|
||
} else {
|
||
setToastMessage({ text: 'Не удалось найти информацию на странице', type: 'warning' })
|
||
}
|
||
} else {
|
||
// Пытаемся получить детальное сообщение об ошибке
|
||
let errorMessage = 'Не удалось загрузить информацию'
|
||
try {
|
||
const errorData = await response.json()
|
||
errorMessage = errorData.message || errorData.error || errorMessage
|
||
} catch (e) {
|
||
const text = await response.text().catch(() => '')
|
||
if (text) {
|
||
try {
|
||
const parsed = JSON.parse(text)
|
||
errorMessage = parsed.message || parsed.error || errorMessage
|
||
} catch {
|
||
// Если не JSON, используем текст как есть (но обрезаем до разумной длины)
|
||
if (text.length < 200) {
|
||
errorMessage = text
|
||
}
|
||
}
|
||
}
|
||
}
|
||
setToastMessage({ text: errorMessage, type: 'error' })
|
||
}
|
||
} catch (err) {
|
||
console.error('Error fetching metadata:', err)
|
||
const errorMessage = err.message || 'Ошибка при загрузке информации'
|
||
setToastMessage({ text: errorMessage, type: 'error' })
|
||
} finally {
|
||
setFetchingMetadata(false)
|
||
}
|
||
}, [authFetch, link, name, price, imageUrl])
|
||
|
||
const handleImageSelect = (e) => {
|
||
const file = e.target.files?.[0]
|
||
if (!file) return
|
||
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
setToastMessage({ text: 'Файл слишком большой (максимум 5MB)', type: 'error' })
|
||
return
|
||
}
|
||
|
||
const reader = new FileReader()
|
||
reader.onload = () => {
|
||
setImageFile(file)
|
||
setImageUrl(reader.result)
|
||
setImageRemoved(false) // Сбрасываем флаг удаления при выборе нового фото
|
||
setShowCropper(true)
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
|
||
// Загрузка картинки по ссылке с последующим кропом
|
||
const loadImageFromUrl = async () => {
|
||
const extracted = extractUrl(imageUrlInput)
|
||
setImageUrlInput(extracted)
|
||
const url = extracted?.trim()
|
||
if (!url) {
|
||
setToastMessage({ text: 'Введите ссылку на картинку', type: 'error' })
|
||
return
|
||
}
|
||
try {
|
||
new URL(url)
|
||
} catch {
|
||
setToastMessage({ text: 'Некорректная ссылка на картинку', type: 'error' })
|
||
return
|
||
}
|
||
|
||
setLoadingImageFromUrl(true)
|
||
try {
|
||
const proxyUrl = `${API_URL}/proxy-image?url=${encodeURIComponent(url)}`
|
||
const imgResponse = await authFetch(proxyUrl)
|
||
if (!imgResponse.ok) {
|
||
const errData = await imgResponse.json().catch(() => ({}))
|
||
throw new Error(errData.message || errData.error || 'Не удалось загрузить картинку')
|
||
}
|
||
const blob = await imgResponse.blob()
|
||
if (blob.size > 5 * 1024 * 1024) {
|
||
setToastMessage({ text: 'Картинка слишком большая (максимум 5MB)', type: 'error' })
|
||
return
|
||
}
|
||
if (!blob.type.startsWith('image/')) {
|
||
setToastMessage({ text: 'По ссылке не изображение', type: 'error' })
|
||
return
|
||
}
|
||
const reader = new FileReader()
|
||
reader.onload = () => {
|
||
setImageUrl(reader.result)
|
||
setImageFile(blob)
|
||
setImageRemoved(false)
|
||
setImageUrlInput('')
|
||
setShowCropper(true)
|
||
}
|
||
reader.readAsDataURL(blob)
|
||
} catch (err) {
|
||
setToastMessage({ text: err.message || 'Ошибка при загрузке картинки по ссылке', type: 'error' })
|
||
} finally {
|
||
setLoadingImageFromUrl(false)
|
||
}
|
||
}
|
||
|
||
const onCropComplete = (croppedArea, croppedAreaPixels) => {
|
||
setCroppedAreaPixels(croppedAreaPixels)
|
||
}
|
||
|
||
const createImage = (url) => {
|
||
return new Promise((resolve, reject) => {
|
||
const image = new Image()
|
||
image.addEventListener('load', () => resolve(image))
|
||
image.addEventListener('error', (error) => reject(error))
|
||
image.src = url
|
||
})
|
||
}
|
||
|
||
const getCroppedImg = async (imageSrc, pixelCrop) => {
|
||
const image = await createImage(imageSrc)
|
||
const canvas = document.createElement('canvas')
|
||
const ctx = canvas.getContext('2d')
|
||
|
||
canvas.width = pixelCrop.width
|
||
canvas.height = pixelCrop.height
|
||
|
||
ctx.drawImage(
|
||
image,
|
||
pixelCrop.x,
|
||
pixelCrop.y,
|
||
pixelCrop.width,
|
||
pixelCrop.height,
|
||
0,
|
||
0,
|
||
pixelCrop.width,
|
||
pixelCrop.height
|
||
)
|
||
|
||
return new Promise((resolve) => {
|
||
canvas.toBlob(resolve, 'image/jpeg', 0.95)
|
||
})
|
||
}
|
||
|
||
const handleCropSave = async () => {
|
||
if (!imageUrl || !croppedAreaPixels) return
|
||
|
||
try {
|
||
const croppedImage = await getCroppedImg(imageUrl, croppedAreaPixels)
|
||
const reader = new FileReader()
|
||
reader.onload = () => {
|
||
setImageUrl(reader.result)
|
||
setImageFile(croppedImage)
|
||
setImageRemoved(false) // Сбрасываем флаг удаления при сохранении обрезки
|
||
setShowCropper(false)
|
||
}
|
||
reader.readAsDataURL(croppedImage)
|
||
} catch (err) {
|
||
setToastMessage({ text: 'Ошибка при обрезке изображения', type: 'error' })
|
||
}
|
||
}
|
||
|
||
const openConditionForm = () => {
|
||
setShowConditionForm(true)
|
||
conditionClosedByPopStateRef.current = false
|
||
window.history.pushState({ conditionForm: true }, '', window.location.href)
|
||
}
|
||
|
||
const closeConditionForm = () => {
|
||
setShowConditionForm(false)
|
||
setEditingConditionIndex(null)
|
||
// Если закрытие через popstate — запись уже убрана, не делаем back
|
||
if (!conditionClosedByPopStateRef.current) {
|
||
window.history.back()
|
||
}
|
||
conditionClosedByPopStateRef.current = false
|
||
}
|
||
|
||
const handleAddCondition = () => {
|
||
setEditingConditionIndex(null)
|
||
openConditionForm()
|
||
}
|
||
|
||
const handleEditCondition = (index) => {
|
||
const condition = unlockConditions[index]
|
||
// Проверяем, что условие принадлежит текущему пользователю
|
||
if (condition.user_id && condition.user_id !== user?.id) {
|
||
setToastMessage({ text: 'Нельзя редактировать чужие цели', type: 'error' })
|
||
return
|
||
}
|
||
setEditingConditionIndex(index)
|
||
openConditionForm()
|
||
}
|
||
|
||
const handleConditionSubmit = (condition) => {
|
||
if (editingConditionIndex !== null) {
|
||
// Редактирование существующего условия
|
||
setUnlockConditions(prev => prev.map((cond, idx) =>
|
||
idx === editingConditionIndex ? { ...condition, display_order: idx } : cond
|
||
))
|
||
} else {
|
||
// Добавление нового условия
|
||
setUnlockConditions([...unlockConditions, { ...condition, display_order: unlockConditions.length }])
|
||
}
|
||
closeConditionForm()
|
||
}
|
||
|
||
const handleConditionCancel = () => {
|
||
closeConditionForm()
|
||
}
|
||
|
||
const handleRemoveCondition = (index) => {
|
||
const condition = unlockConditions[index]
|
||
// Проверяем, что условие принадлежит текущему пользователю
|
||
if (condition.user_id && condition.user_id !== user?.id) {
|
||
setToastMessage({ text: 'Нельзя удалять чужие цели', type: 'error' })
|
||
return
|
||
}
|
||
setUnlockConditions(unlockConditions.filter((_, i) => i !== index))
|
||
}
|
||
|
||
// Обработчик для создания задачи из ConditionForm
|
||
const handleCreateTaskFromCondition = () => {
|
||
// Закрываем диалог цели перед переходом
|
||
setShowConditionForm(false)
|
||
|
||
// Сохранить текущее состояние формы
|
||
const stateToSave = {
|
||
name,
|
||
price,
|
||
link,
|
||
imageUrl,
|
||
unlockConditions,
|
||
pendingConditionType: 'task_completion',
|
||
editingConditionIndex,
|
||
}
|
||
console.log('[WishlistForm] Saving state and navigating to task-form:', stateToSave)
|
||
sessionStorage.setItem(WISHLIST_FORM_STATE_KEY, JSON.stringify(stateToSave))
|
||
|
||
// Навигация на форму создания задачи
|
||
const navParams = {
|
||
returnTo: 'wishlist-form',
|
||
returnWishlistId: wishlistId,
|
||
}
|
||
console.log('[WishlistForm] Navigation params:', navParams)
|
||
onNavigate?.('task-form', navParams)
|
||
}
|
||
|
||
const handleSubmit = async (e) => {
|
||
e.preventDefault()
|
||
setError('')
|
||
setLoading(true)
|
||
|
||
if (!name.trim()) {
|
||
setError('Название обязательно')
|
||
setLoading(false)
|
||
return
|
||
}
|
||
|
||
try {
|
||
const payload = {
|
||
name: name.trim(),
|
||
price: price ? parseFloat(price) : null,
|
||
link: link.trim() || null,
|
||
group_name: groupName.trim() || null,
|
||
unlock_conditions: unlockConditions.map(cond => ({
|
||
id: cond.id || null,
|
||
type: cond.type,
|
||
task_id: cond.type === 'task_completion' ? cond.task_id : null,
|
||
project_id: cond.type === 'project_points' ? cond.project_id : null,
|
||
required_points: cond.type === 'project_points' ? parseFloat(cond.required_points) : null,
|
||
start_date: cond.type === 'project_points' ? cond.start_date : null,
|
||
})),
|
||
}
|
||
|
||
let url, method
|
||
if (wishlistId) {
|
||
// Редактирование существующего желания
|
||
url = `${API_URL}/${wishlistId}`
|
||
method = 'PUT'
|
||
} else {
|
||
// Создание нового желания
|
||
if (boardId) {
|
||
// Создание на доске
|
||
url = `/api/wishlist/boards/${boardId}/items`
|
||
} else {
|
||
// Старый API для обратной совместимости
|
||
url = API_URL
|
||
}
|
||
method = 'POST'
|
||
}
|
||
|
||
const response = await authFetch(url, {
|
||
method,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(payload),
|
||
})
|
||
|
||
if (!response.ok) {
|
||
let errorMessage = 'Ошибка при сохранении'
|
||
try {
|
||
const errorData = await response.json()
|
||
errorMessage = errorData.message || errorData.error || errorMessage
|
||
} catch (e) {
|
||
const text = await response.text().catch(() => '')
|
||
if (text) errorMessage = text
|
||
}
|
||
throw new Error(errorMessage)
|
||
}
|
||
|
||
const savedItem = await response.json()
|
||
const itemId = savedItem.id || wishlistId
|
||
|
||
// Удаляем картинку если она была удалена пользователем
|
||
if (imageRemoved && itemId) {
|
||
const deleteResponse = await authFetch(`${API_URL}/${itemId}/image`, {
|
||
method: 'DELETE',
|
||
})
|
||
|
||
if (!deleteResponse.ok) {
|
||
setToastMessage({ text: 'Желание сохранено, но ошибка при удалении картинки', type: 'warning' })
|
||
}
|
||
}
|
||
|
||
// Загружаем картинку если есть новое фото
|
||
if (imageFile && itemId && !imageRemoved) {
|
||
const formData = new FormData()
|
||
formData.append('image', imageFile)
|
||
|
||
const imageResponse = await authFetch(`${API_URL}/${itemId}/image`, {
|
||
method: 'POST',
|
||
body: formData,
|
||
})
|
||
|
||
if (!imageResponse.ok) {
|
||
setToastMessage({ text: 'Желание сохранено, но ошибка при загрузке картинки', type: 'warning' })
|
||
} else {
|
||
// Обновляем imageUrl после успешной загрузки
|
||
const imageData = await imageResponse.json()
|
||
if (imageData.image_url) {
|
||
setImageUrl(imageData.image_url)
|
||
}
|
||
}
|
||
}
|
||
|
||
resetForm()
|
||
// Возврат назад по стеку истории
|
||
window.history.back()
|
||
} catch (err) {
|
||
setError(err.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleCancel = () => {
|
||
resetForm()
|
||
window.history.back()
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="wishlist-form">
|
||
<button className="close-x-button" onClick={handleCancel}>
|
||
✕
|
||
</button>
|
||
{loadingWishlist ? (
|
||
<div className="fixed inset-0 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>
|
||
) : (
|
||
<>
|
||
<h2>{wishlistId ? 'Редактировать желание' : 'Новое желание'}</h2>
|
||
|
||
<form id="wishlist-form-element" onSubmit={handleSubmit}>
|
||
<div className="form-group">
|
||
<label htmlFor="link">Ссылка</label>
|
||
<div className="link-input-wrapper">
|
||
<input
|
||
id="link"
|
||
type="text"
|
||
value={link}
|
||
onChange={(e) => setLink(e.target.value)}
|
||
onBlur={() => setLink(extractUrl(link))}
|
||
placeholder="https://..."
|
||
className="form-input"
|
||
disabled={fetchingMetadata}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="pull-metadata-button"
|
||
onClick={fetchLinkMetadata}
|
||
disabled={fetchingMetadata || !link.trim()}
|
||
title="Загрузить информацию из ссылки"
|
||
>
|
||
{fetchingMetadata ? (
|
||
<div className="mini-spinner"></div>
|
||
) : (
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||
<polyline points="7 10 12 15 17 10"/>
|
||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||
</svg>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="name">Название *</label>
|
||
<input
|
||
id="name"
|
||
type="text"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
required
|
||
className="form-input"
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="price">Цена</label>
|
||
<input
|
||
id="price"
|
||
type="number"
|
||
step="0.01"
|
||
value={price}
|
||
onChange={(e) => setPrice(e.target.value)}
|
||
placeholder="0.00"
|
||
className="form-input"
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="group">Группа</label>
|
||
<GroupAutocomplete
|
||
suggestions={groupSuggestions}
|
||
value={groupName}
|
||
onChange={setGroupName}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>Картинка</label>
|
||
{imageUrl && !showCropper && (
|
||
<div className="image-preview">
|
||
<img
|
||
src={imageUrl}
|
||
alt="Preview"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
style={{ cursor: 'pointer' }}
|
||
title="Нажмите, чтобы изменить"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setImageUrl(null)
|
||
setImageFile(null)
|
||
setImageRemoved(true) // Устанавливаем флаг удаления
|
||
// Сбрасываем file input, чтобы можно было выбрать новое фото
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = ''
|
||
}
|
||
}}
|
||
className="remove-image-button"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
)}
|
||
{!imageUrl && (
|
||
<>
|
||
<div className="image-url-row">
|
||
<span className="image-url-label">Файл:</span>
|
||
<label className="file-input-label">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleImageSelect}
|
||
className="file-input-hidden"
|
||
/>
|
||
<span className="file-input-button">Выбрать</span>
|
||
</label>
|
||
</div>
|
||
<div className="image-url-row">
|
||
<span className="image-url-label">Ссылка:</span>
|
||
<input
|
||
type="text"
|
||
value={imageUrlInput}
|
||
onChange={(e) => setImageUrlInput(e.target.value)}
|
||
onBlur={() => setImageUrlInput(extractUrl(imageUrlInput))}
|
||
placeholder="https://..."
|
||
className="form-input image-url-input"
|
||
disabled={loadingImageFromUrl}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="image-url-load-button"
|
||
onClick={loadImageFromUrl}
|
||
disabled={loadingImageFromUrl || !imageUrlInput.trim()}
|
||
title="Загрузить картинку по ссылке и обрезать"
|
||
>
|
||
{loadingImageFromUrl ? (
|
||
<div className="mini-spinner"></div>
|
||
) : (
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||
<polyline points="7 10 12 15 17 10"/>
|
||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||
</svg>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{showCropper && (
|
||
<div className="cropper-modal">
|
||
<div className="cropper-container">
|
||
<Cropper
|
||
image={imageUrl}
|
||
crop={crop}
|
||
zoom={zoom}
|
||
aspect={5 / 6}
|
||
onCropChange={setCrop}
|
||
onZoomChange={setZoom}
|
||
onCropComplete={onCropComplete}
|
||
/>
|
||
</div>
|
||
<div className="cropper-controls">
|
||
<label>
|
||
Масштаб:
|
||
<input
|
||
type="range"
|
||
min={1}
|
||
max={3}
|
||
step={0.1}
|
||
value={zoom}
|
||
onChange={(e) => setZoom(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className="cropper-actions">
|
||
<button type="button" onClick={() => setShowCropper(false)}>
|
||
Отмена
|
||
</button>
|
||
<button type="button" onClick={handleCropSave}>
|
||
Сохранить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="form-group">
|
||
<label>Цель</label>
|
||
{unlockConditions.length > 0 && (
|
||
<div className="conditions-list">
|
||
{unlockConditions.map((cond, idx) => {
|
||
const isOwnCondition = !cond.user_id || cond.user_id === user?.id
|
||
return (
|
||
<div key={idx} className="condition-item">
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<span
|
||
className={`condition-item-text ${!isOwnCondition ? 'condition-item-other-user' : ''}`}
|
||
onClick={() => isOwnCondition && handleEditCondition(idx)}
|
||
style={{ cursor: isOwnCondition ? 'pointer' : 'default', paddingBottom: '0.125rem' }}
|
||
title={!isOwnCondition ? 'Чужая цель - нельзя редактировать' : ''}
|
||
>
|
||
{cond.type === 'task_completion'
|
||
? tasks.find(t => t.id === cond.task_id)?.name || 'Не выбрана'
|
||
: `${cond.required_points} в ${projects.find(p => p.project_id === cond.project_id)?.project_name || cond.project_name || 'Не выбран'}${cond.start_date ? ` с ${new Date(cond.start_date + 'T00:00:00').toLocaleDateString('ru-RU')}` : ' за всё время'}`}
|
||
</span>
|
||
{cond.type === 'project_points' && cond.weeks_text && (
|
||
<div style={{ color: '#666', fontSize: '0.85em' }}>
|
||
<span>Срок: </span>
|
||
<span style={{ fontWeight: '600' }}>{cond.weeks_text}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{isOwnCondition && (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleRemoveCondition(idx)}
|
||
className="remove-condition-button"
|
||
>
|
||
✕
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={handleAddCondition}
|
||
className="add-condition-button"
|
||
>
|
||
Добавить цель
|
||
</button>
|
||
</div>
|
||
|
||
{error && <div className="error-message">{error}</div>}
|
||
|
||
</form>
|
||
|
||
{showConditionForm && (
|
||
<ConditionForm
|
||
tasks={tasks}
|
||
projects={projects}
|
||
onSubmit={handleConditionSubmit}
|
||
onCancel={handleConditionCancel}
|
||
editingCondition={editingConditionIndex !== null ? unlockConditions[editingConditionIndex] : null}
|
||
onCreateTask={handleCreateTaskFromCondition}
|
||
preselectedTaskId={newTaskConsumed ? undefined : newTaskId}
|
||
authFetch={authFetch}
|
||
/>
|
||
)}
|
||
|
||
{toastMessage && (
|
||
<Toast
|
||
message={toastMessage.text}
|
||
type={toastMessage.type}
|
||
onClose={() => setToastMessage(null)}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
{isActive ? 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
|
||
type="submit"
|
||
form="wishlist-form-element"
|
||
disabled={loading}
|
||
style={{
|
||
width: '100%',
|
||
maxWidth: '42rem',
|
||
padding: '0.875rem',
|
||
background: loading ? undefined : 'linear-gradient(to right, #10b981, #059669)',
|
||
backgroundColor: loading ? '#9ca3af' : undefined,
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '0.5rem',
|
||
fontSize: '1rem',
|
||
fontWeight: 600,
|
||
cursor: loading ? 'not-allowed' : 'pointer',
|
||
opacity: loading ? 0.6 : 1,
|
||
transition: 'all 0.2s',
|
||
}}
|
||
>
|
||
{loading ? 'Сохранение...' : 'Сохранить'}
|
||
</button>
|
||
</div>,
|
||
document.body
|
||
) : null}
|
||
</>
|
||
)
|
||
}
|
||
|
||
// Компонент селектора даты с календарём (аналогично TaskList)
|
||
function DateSelector({ value, onChange, placeholder = "За всё время" }) {
|
||
const dateInputRef = useRef(null)
|
||
|
||
const formatDateForDisplay = (dateStr) => {
|
||
if (!dateStr) return ''
|
||
const date = new Date(dateStr + 'T00:00:00')
|
||
const now = new Date()
|
||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||
const targetDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||
|
||
const diffDays = Math.floor((targetDate - today) / (1000 * 60 * 60 * 24))
|
||
|
||
const monthNames = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
|
||
|
||
if (diffDays === 0) {
|
||
return 'Сегодня'
|
||
} else if (diffDays === 1) {
|
||
return 'Завтра'
|
||
} else if (diffDays === -1) {
|
||
return 'Вчера'
|
||
} else if (diffDays > 1 && diffDays <= 7) {
|
||
const dayOfWeek = targetDate.getDay()
|
||
const dayNames = ['воскресенье', 'понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота']
|
||
return dayNames[dayOfWeek]
|
||
} else if (targetDate.getFullYear() === now.getFullYear()) {
|
||
return `${targetDate.getDate()} ${monthNames[targetDate.getMonth()]}`
|
||
} else {
|
||
return `${targetDate.getDate()} ${monthNames[targetDate.getMonth()]} ${targetDate.getFullYear()}`
|
||
}
|
||
}
|
||
|
||
const handleDisplayClick = () => {
|
||
if (dateInputRef.current) {
|
||
if (typeof dateInputRef.current.showPicker === 'function') {
|
||
dateInputRef.current.showPicker()
|
||
} else {
|
||
dateInputRef.current.focus()
|
||
dateInputRef.current.click()
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleClear = (e) => {
|
||
e.stopPropagation()
|
||
onChange('')
|
||
}
|
||
|
||
return (
|
||
<div className="date-selector-input-group">
|
||
<input
|
||
ref={dateInputRef}
|
||
type="date"
|
||
value={value || ''}
|
||
onChange={(e) => onChange(e.target.value || '')}
|
||
className="date-selector-input"
|
||
/>
|
||
<div
|
||
className="date-selector-display-date"
|
||
onClick={handleDisplayClick}
|
||
>
|
||
{value ? formatDateForDisplay(value) : placeholder}
|
||
</div>
|
||
{value && (
|
||
<button
|
||
type="button"
|
||
onClick={handleClear}
|
||
className="date-selector-clear-button"
|
||
aria-label="Очистить дату"
|
||
>
|
||
✕
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Компонент автодополнения для выбора группы
|
||
function GroupAutocomplete({ suggestions, value, onChange }) {
|
||
const [inputValue, setInputValue] = useState('')
|
||
const [isOpen, setIsOpen] = useState(false)
|
||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||
const wrapperRef = useRef(null)
|
||
const inputRef = useRef(null)
|
||
|
||
// При изменении value - обновить inputValue
|
||
useEffect(() => {
|
||
setInputValue(value || '')
|
||
}, [value])
|
||
|
||
// Фильтрация саджестов
|
||
const filteredSuggestions = inputValue.trim()
|
||
? suggestions.filter(group =>
|
||
group.toLowerCase().includes(inputValue.toLowerCase())
|
||
)
|
||
: suggestions
|
||
|
||
// Закрытие при клике снаружи
|
||
useEffect(() => {
|
||
const handleClickOutside = (e) => {
|
||
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
|
||
setIsOpen(false)
|
||
// Восстанавливаем значение
|
||
setInputValue(value || '')
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', handleClickOutside)
|
||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||
}, [value])
|
||
|
||
const handleInputChange = (e) => {
|
||
const newValue = e.target.value
|
||
setInputValue(newValue)
|
||
setIsOpen(true)
|
||
setHighlightedIndex(-1)
|
||
onChange(newValue)
|
||
}
|
||
|
||
const handleSelectGroup = (group) => {
|
||
onChange(group)
|
||
setInputValue(group)
|
||
setIsOpen(false)
|
||
setHighlightedIndex(-1)
|
||
}
|
||
|
||
const handleKeyDown = (e) => {
|
||
if (!isOpen) {
|
||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||
setIsOpen(true)
|
||
e.preventDefault()
|
||
}
|
||
return
|
||
}
|
||
|
||
switch (e.key) {
|
||
case 'ArrowDown':
|
||
e.preventDefault()
|
||
setHighlightedIndex(prev =>
|
||
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
|
||
)
|
||
break
|
||
case 'ArrowUp':
|
||
e.preventDefault()
|
||
setHighlightedIndex(prev => prev > 0 ? prev - 1 : -1)
|
||
break
|
||
case 'Enter':
|
||
e.preventDefault()
|
||
if (highlightedIndex >= 0 && filteredSuggestions[highlightedIndex]) {
|
||
handleSelectGroup(filteredSuggestions[highlightedIndex])
|
||
}
|
||
break
|
||
case 'Escape':
|
||
setIsOpen(false)
|
||
setInputValue(value || '')
|
||
break
|
||
}
|
||
}
|
||
|
||
const handleFocus = () => {
|
||
setIsOpen(true)
|
||
}
|
||
|
||
return (
|
||
<div className="group-autocomplete" ref={wrapperRef}>
|
||
<div className="group-autocomplete-input-wrapper">
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={inputValue}
|
||
onChange={handleInputChange}
|
||
onFocus={handleFocus}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="Введите название группы..."
|
||
className="form-input"
|
||
autoComplete="off"
|
||
/>
|
||
{inputValue && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setInputValue('')
|
||
onChange('')
|
||
inputRef.current?.focus()
|
||
}}
|
||
className="group-autocomplete-clear"
|
||
>
|
||
✕
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{isOpen && filteredSuggestions.length > 0 && (
|
||
<div className="group-autocomplete-dropdown">
|
||
{filteredSuggestions.map((group, index) => (
|
||
<div
|
||
key={group}
|
||
className={`group-autocomplete-item ${
|
||
highlightedIndex === index ? 'highlighted' : ''
|
||
}`}
|
||
onClick={() => handleSelectGroup(group)}
|
||
onMouseEnter={() => setHighlightedIndex(index)}
|
||
>
|
||
{group}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Компонент автодополнения для выбора задачи
|
||
function TaskAutocomplete({ tasks, value, onChange, onCreateTask, preselectedTaskId }) {
|
||
const [inputValue, setInputValue] = useState('')
|
||
const [isOpen, setIsOpen] = useState(false)
|
||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||
const wrapperRef = useRef(null)
|
||
const inputRef = useRef(null)
|
||
|
||
// Найти выбранную задачу по ID
|
||
const selectedTask = tasks.find(t => t.id === value)
|
||
|
||
// При изменении selectedTask или value - обновить inputValue
|
||
useEffect(() => {
|
||
if (selectedTask) {
|
||
setInputValue(selectedTask.name)
|
||
} else if (!value) {
|
||
setInputValue('')
|
||
}
|
||
}, [selectedTask, value])
|
||
|
||
// При preselectedTaskId автоматически выбрать задачу
|
||
useEffect(() => {
|
||
if (preselectedTaskId && !value && tasks.length > 0) {
|
||
const task = tasks.find(t => t.id === preselectedTaskId)
|
||
if (task && value !== preselectedTaskId) {
|
||
onChange(preselectedTaskId)
|
||
setInputValue(task.name)
|
||
}
|
||
}
|
||
}, [preselectedTaskId, tasks.length, value, onChange])
|
||
|
||
// Фильтрация задач
|
||
const filteredTasks = inputValue.trim()
|
||
? tasks.filter(task =>
|
||
task.name.toLowerCase().includes(inputValue.toLowerCase())
|
||
)
|
||
: tasks
|
||
|
||
// Закрытие при клике снаружи
|
||
useEffect(() => {
|
||
const handleClickOutside = (e) => {
|
||
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
|
||
setIsOpen(false)
|
||
// Восстанавливаем название выбранной задачи
|
||
if (selectedTask) {
|
||
setInputValue(selectedTask.name)
|
||
} else if (!value) {
|
||
setInputValue('')
|
||
}
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', handleClickOutside)
|
||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||
}, [selectedTask, value])
|
||
|
||
const handleInputChange = (e) => {
|
||
setInputValue(e.target.value)
|
||
setIsOpen(true)
|
||
setHighlightedIndex(-1)
|
||
// Сбрасываем выбор, если пользователь изменил текст
|
||
if (selectedTask && e.target.value !== selectedTask.name) {
|
||
onChange(null)
|
||
}
|
||
}
|
||
|
||
const handleSelectTask = (task) => {
|
||
onChange(task.id)
|
||
setInputValue(task.name)
|
||
setIsOpen(false)
|
||
setHighlightedIndex(-1)
|
||
}
|
||
|
||
const handleKeyDown = (e) => {
|
||
if (!isOpen) {
|
||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||
setIsOpen(true)
|
||
e.preventDefault()
|
||
}
|
||
return
|
||
}
|
||
|
||
switch (e.key) {
|
||
case 'ArrowDown':
|
||
e.preventDefault()
|
||
setHighlightedIndex(prev =>
|
||
prev < filteredTasks.length - 1 ? prev + 1 : prev
|
||
)
|
||
break
|
||
case 'ArrowUp':
|
||
e.preventDefault()
|
||
setHighlightedIndex(prev => prev > 0 ? prev - 1 : -1)
|
||
break
|
||
case 'Enter':
|
||
e.preventDefault()
|
||
if (highlightedIndex >= 0 && filteredTasks[highlightedIndex]) {
|
||
handleSelectTask(filteredTasks[highlightedIndex])
|
||
}
|
||
break
|
||
case 'Escape':
|
||
setIsOpen(false)
|
||
if (selectedTask) {
|
||
setInputValue(selectedTask.name)
|
||
} else {
|
||
setInputValue('')
|
||
}
|
||
break
|
||
}
|
||
}
|
||
|
||
const handleFocus = () => {
|
||
setIsOpen(true)
|
||
}
|
||
|
||
return (
|
||
<div className="task-autocomplete" ref={wrapperRef}>
|
||
<div className="task-autocomplete-row">
|
||
<div className="task-autocomplete-input-wrapper">
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={inputValue}
|
||
onChange={handleInputChange}
|
||
onFocus={handleFocus}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="Начните вводить название..."
|
||
className="task-autocomplete-input"
|
||
autoComplete="off"
|
||
/>
|
||
{inputValue && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setInputValue('')
|
||
onChange(null)
|
||
inputRef.current?.focus()
|
||
}}
|
||
className="task-autocomplete-clear"
|
||
>
|
||
✕
|
||
</button>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onCreateTask}
|
||
className="create-task-button"
|
||
title="Создать новую задачу"
|
||
>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{isOpen && (
|
||
<div className="task-autocomplete-dropdown">
|
||
{filteredTasks.length === 0 ? (
|
||
<div className="task-autocomplete-empty">
|
||
{inputValue ? 'Задачи не найдены' : 'Нет доступных задач'}
|
||
</div>
|
||
) : (
|
||
filteredTasks.map((task, index) => (
|
||
<div
|
||
key={task.id}
|
||
className={`task-autocomplete-item ${
|
||
value === task.id ? 'selected' : ''
|
||
} ${highlightedIndex === index ? 'highlighted' : ''}`}
|
||
onClick={() => handleSelectTask(task)}
|
||
onMouseEnter={() => setHighlightedIndex(index)}
|
||
>
|
||
{task.name}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Компонент формы цели
|
||
function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition, onCreateTask, preselectedTaskId, authFetch }) {
|
||
const [type, setType] = useState(editingCondition?.type || 'project_points')
|
||
const [taskId, setTaskId] = useState(editingCondition?.task_id || null)
|
||
const [projectId, setProjectId] = useState(editingCondition?.project_id?.toString() || '')
|
||
const [requiredPoints, setRequiredPoints] = useState(editingCondition?.required_points?.toString() || '')
|
||
const [startDate, setStartDate] = useState(editingCondition?.start_date || '')
|
||
const [calculatedWeeksText, setCalculatedWeeksText] = useState(
|
||
editingCondition?.type === 'project_points' ? (editingCondition?.weeks_text ?? null) : null
|
||
)
|
||
|
||
const isEditing = editingCondition !== null
|
||
|
||
// Показываем срок разблокировки из редактируемого условия до прихода ответа API
|
||
useEffect(() => {
|
||
if (editingCondition?.type === 'project_points' && editingCondition?.weeks_text) {
|
||
setCalculatedWeeksText(editingCondition.weeks_text)
|
||
}
|
||
}, [editingCondition?.id, editingCondition?.type, editingCondition?.weeks_text])
|
||
|
||
// Автоподстановка новой задачи
|
||
useEffect(() => {
|
||
if (preselectedTaskId && !editingCondition) {
|
||
setType('task_completion')
|
||
setTaskId(preselectedTaskId)
|
||
}
|
||
}, [preselectedTaskId, editingCondition])
|
||
|
||
// Расчет недель при изменении проекта, баллов или даты
|
||
useEffect(() => {
|
||
const calculateWeeks = async () => {
|
||
if (type === 'project_points' && projectId && requiredPoints && authFetch) {
|
||
try {
|
||
const response = await authFetch('/api/wishlist/calculate-weeks', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
project_id: parseInt(projectId),
|
||
required_points: parseFloat(requiredPoints),
|
||
start_date: startDate || '',
|
||
condition_user_id: editingCondition?.user_id || null,
|
||
}),
|
||
})
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setCalculatedWeeksText(data.weeks_text || null)
|
||
} else {
|
||
setCalculatedWeeksText(null)
|
||
}
|
||
} catch (err) {
|
||
console.error('Error calculating weeks:', err)
|
||
setCalculatedWeeksText(null)
|
||
}
|
||
} else {
|
||
setCalculatedWeeksText(null)
|
||
}
|
||
}
|
||
calculateWeeks()
|
||
}, [type, projectId, requiredPoints, startDate, editingCondition?.user_id, authFetch])
|
||
|
||
const handleSubmit = (e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation() // Предотвращаем всплытие события
|
||
|
||
// Валидация
|
||
if (type === 'task_completion' && (!taskId || taskId === null)) {
|
||
return
|
||
}
|
||
if (type === 'project_points' && (!projectId || !requiredPoints)) {
|
||
return
|
||
}
|
||
|
||
const condition = {
|
||
type,
|
||
task_id: type === 'task_completion' ? (typeof taskId === 'number' ? taskId : parseInt(taskId)) : null,
|
||
project_id: type === 'project_points' ? parseInt(projectId) : null,
|
||
required_points: type === 'project_points' ? parseFloat(requiredPoints) : null,
|
||
start_date: type === 'project_points' && startDate ? startDate : null,
|
||
...(type === 'project_points' && {
|
||
weeks_text: calculatedWeeksText || editingCondition?.weeks_text || null,
|
||
}),
|
||
...(editingCondition?.id != null && { id: editingCondition.id }),
|
||
...(editingCondition?.user_id != null && { user_id: editingCondition.user_id }),
|
||
}
|
||
onSubmit(condition)
|
||
// Сброс формы
|
||
setType('project_points')
|
||
setTaskId(null)
|
||
setProjectId('')
|
||
setRequiredPoints('')
|
||
setStartDate('')
|
||
}
|
||
|
||
return (
|
||
<div className="condition-form-overlay" onClick={onCancel}>
|
||
<div className="condition-form" onClick={(e) => e.stopPropagation()}>
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="condition-tabs-container">
|
||
<div className="condition-tabs-inner">
|
||
<button
|
||
type="button"
|
||
className={`condition-tab-button ${type === 'project_points' ? 'active' : ''}`}
|
||
onClick={() => setType('project_points')}
|
||
>
|
||
Баллы
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`condition-tab-button ${type === 'task_completion' ? 'active' : ''}`}
|
||
onClick={() => setType('task_completion')}
|
||
>
|
||
Задача
|
||
</button>
|
||
</div>
|
||
<button type="button" onClick={onCancel} className="condition-form-close-button condition-tabs-close">
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{type === 'task_completion' && (
|
||
<div className="form-group">
|
||
<label>Задача</label>
|
||
<TaskAutocomplete
|
||
tasks={tasks}
|
||
value={taskId}
|
||
onChange={(id) => setTaskId(id)}
|
||
onCreateTask={onCreateTask}
|
||
preselectedTaskId={preselectedTaskId}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{type === 'project_points' && (
|
||
<>
|
||
<div className="form-group">
|
||
<label>Проект</label>
|
||
<select
|
||
value={projectId}
|
||
onChange={(e) => setProjectId(e.target.value)}
|
||
className="form-input"
|
||
required
|
||
>
|
||
<option value="">Выберите проект</option>
|
||
{projects.map(project => (
|
||
<option key={project.project_id} value={project.project_id}>
|
||
{project.project_name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Необходимо баллов</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={requiredPoints}
|
||
onChange={(e) => setRequiredPoints(e.target.value)}
|
||
className="form-input"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Дата начала подсчёта</label>
|
||
<DateSelector
|
||
value={startDate}
|
||
onChange={setStartDate}
|
||
placeholder="За всё время"
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div className="form-actions">
|
||
<button type="submit" className="submit-button condition-form-submit-button">
|
||
{isEditing ? 'Сохранить' : 'Добавить'}
|
||
</button>
|
||
</div>
|
||
{type === 'project_points' && calculatedWeeksText && (
|
||
<div className="calculated-weeks-info">
|
||
<span>Срок: </span>
|
||
<span style={{ fontWeight: '600' }}>{calculatedWeeksText}</span>
|
||
</div>
|
||
)}
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default WishlistForm
|
||
|