Files
play-life/play-life-web/src/components/WishlistForm.jsx
poignatov 763b13358e
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m8s
4.8.2: Улучшены отступы в форме редактирования цели
2026-02-02 19:53:58 +03:00

1317 lines
47 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 Cropper from 'react-easy-crop'
import { useAuth } from './auth/AuthContext'
import Toast from './Toast'
import './WishlistForm.css'
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, boardId }) {
const { authFetch, user } = useAuth()
const [name, setName] = useState('')
const [price, setPrice] = useState('')
const [link, setLink] = useState('')
const [imageUrl, setImageUrl] = useState(null)
const [imageFile, setImageFile] = useState(null)
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 [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(false) // Флаг восстановления из sessionStorage
const [loadedWishlistData, setLoadedWishlistData] = useState(null) // Данные желания для последующего маппинга условий
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 : [])
}
// Загружаем проекты
const projectsResponse = await authFetch(PROJECTS_API_URL)
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json()
setProjects(Array.isArray(projectsData) ? projectsData : [])
}
} catch (err) {
console.error('Error loading data:', err)
}
}
loadData()
}, [])
// Загрузка желания при редактировании или сброс формы при создании
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)
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])
// Восстановление состояния при возврате с создания задачи
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)
// Восстанавливаем условия и автоматически добавляем новую задачу
const restoredConditions = state.unlockConditions || []
console.log('[WishlistForm] Restored conditions:', restoredConditions)
// Перезагружаем задачи, чтобы новая задача была в списке
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)
}
} else {
setUnlockConditions(restoredConditions)
}
// Устанавливаем флаг, что состояние восстановлено
setRestoredFromSession(true)
}
} 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)
setUnlockConditions([])
setError('')
setShowCropper(false)
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedAreaPixels(null)
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 при загрузке существующего желания
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)
}
} catch (err) {
setError(err.message)
} finally {
setLoadingWishlist(false)
}
}
const resetForm = () => {
setName('')
setPrice('')
setLink('')
setImageUrl(null)
setImageFile(null)
setUnlockConditions([])
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
}
// Проверяем валидность URL
try {
new URL(link)
} 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: link.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)
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)
setShowCropper(true)
}
reader.readAsDataURL(file)
}
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)
setShowCropper(false)
}
reader.readAsDataURL(croppedImage)
} catch (err) {
setToastMessage({ text: 'Ошибка при обрезке изображения', type: 'error' })
}
}
const handleAddCondition = () => {
setEditingConditionIndex(null)
setShowConditionForm(true)
}
const handleEditCondition = (index) => {
const condition = unlockConditions[index]
// Проверяем, что условие принадлежит текущему пользователю
if (condition.user_id && condition.user_id !== user?.id) {
setToastMessage({ text: 'Нельзя редактировать чужие цели', type: 'error' })
return
}
setEditingConditionIndex(index)
setShowConditionForm(true)
}
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 }])
}
setShowConditionForm(false)
setEditingConditionIndex(null)
}
const handleConditionCancel = () => {
setShowConditionForm(false)
setEditingConditionIndex(null)
}
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 = () => {
// Сохранить текущее состояние формы
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,
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 (imageFile && itemId) {
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()
// Возвращаемся на доску, если она была указана
if (boardId) {
onNavigate?.('wishlist', { boardId })
} else {
onNavigate?.('wishlist')
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleCancel = () => {
resetForm()
// Возвращаемся на доску, если она была указана
if (boardId) {
onNavigate?.('wishlist', { boardId })
} else {
onNavigate?.('wishlist')
}
}
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 onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="link">Ссылка</label>
<div className="link-input-wrapper">
<input
id="link"
type="url"
value={link}
onChange={(e) => setLink(e.target.value)}
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>Картинка</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)
}}
className="remove-image-button"
>
</button>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
className="form-input"
style={{ display: imageUrl ? 'none' : 'block' }}
/>
</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>}
<div className="form-actions">
<button type="submit" disabled={loading} className="submit-button">
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</form>
{showConditionForm && (
<ConditionForm
tasks={tasks}
projects={projects}
onSubmit={handleConditionSubmit}
onCancel={handleConditionCancel}
editingCondition={editingConditionIndex !== null ? unlockConditions[editingConditionIndex] : null}
onCreateTask={handleCreateTaskFromCondition}
preselectedTaskId={newTaskId}
authFetch={authFetch}
/>
)}
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</>
)}
</div>
)
}
// Компонент селектора даты с календарём (аналогично 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 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(null)
const isEditing = editingCondition !== null
// Автоподстановка новой задачи
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,
}
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()}>
<div className="condition-form-header">
<h3>{isEditing ? 'Редактировать цель' : 'Добавить цель'}</h3>
<button onClick={onCancel} className="condition-form-close-button">
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Тип условия</label>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="form-input"
>
<option value="project_points">Баллы</option>
<option value="task_completion">Задача</option>
</select>
</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' && (
<div className="calculated-weeks-info">
{calculatedWeeksText && (
<>
<span>Срок: </span>
<span style={{ fontWeight: '600' }}>{calculatedWeeksText}</span>
</>
)}
</div>
)}
</form>
</div>
</div>
)
}
export default WishlistForm