Files
play-life/play-life-web/src/components/WishlistForm.jsx

1239 lines
44 KiB
React
Raw Normal View History

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 } = 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,
})))
} 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,
})))
} 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 {
// Загружаем изображение напрямую
const imgResponse = await fetch(metadata.image)
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) => {
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) => {
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')
}
}
if (loadingWishlist) {
return (
<div className="wishlist-form">
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
</div>
)
}
return (
<div className="wishlist-form">
<button className="close-x-button" onClick={handleCancel}>
</button>
<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) => (
<div key={idx} className="condition-item">
<span
className="condition-item-text"
onClick={() => handleEditCondition(idx)}
>
{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>
<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}
/>
)}
{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 }) {
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 isEditing = editingCondition !== null
// Автоподстановка новой задачи
useEffect(() => {
if (preselectedTaskId && !editingCondition) {
setType('task_completion')
setTaskId(preselectedTaskId)
}
}, [preselectedTaskId, editingCondition])
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()}>
<h3>{isEditing ? 'Редактировать цель' : 'Добавить цель'}</h3>
<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="button" onClick={onCancel} className="cancel-button">
Отмена
</button>
<button type="submit" className="submit-button">
{isEditing ? 'Сохранить' : 'Добавить'}
</button>
</div>
</form>
</div>
</div>
)
}
export default WishlistForm