2026-01-12 17:02:33 +03:00
import React , { useState , useEffect , useCallback , useRef } from 'react'
2026-01-11 21:12:26 +03:00
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'
2026-01-13 20:55:44 +03:00
const WISHLIST _FORM _STATE _KEY = 'wishlistFormPendingState'
2026-01-11 21:12:26 +03:00
2026-01-13 20:55:44 +03:00
function WishlistForm ( { onNavigate , wishlistId , editConditionIndex , newTaskId } ) {
2026-01-11 21:12:26 +03:00
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 )
2026-01-12 17:42:51 +03:00
const [ editingConditionIndex , setEditingConditionIndex ] = useState ( null )
2026-01-11 21:12:26 +03:00
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 )
2026-01-13 20:55:44 +03:00
const [ restoredFromSession , setRestoredFromSession ] = useState ( false ) // Флаг восстановления из sessionStorage
2026-01-12 17:42:51 +03:00
const fileInputRef = useRef ( null )
2026-01-11 21:12:26 +03:00
// Загрузка задач и проектов
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 ( )
} , [ ] )
2026-01-12 18:58:52 +03:00
// Загрузка желания при редактировании или с б р о с формы при создании
2026-01-11 21:12:26 +03:00
useEffect ( ( ) => {
2026-01-13 20:55:44 +03:00
// Пропускаем загрузку, если состояние было восстановлено из sessionStorage
if ( restoredFromSession ) {
console . log ( '[WishlistForm] Skipping loadWishlist - restored from session' )
return
}
2026-01-11 21:12:26 +03:00
if ( wishlistId !== undefined && wishlistId !== null && tasks . length > 0 && projects . length > 0 ) {
loadWishlist ( )
} else if ( wishlistId === undefined || wishlistId === null ) {
2026-01-12 18:58:52 +03:00
// Сбрасываем форму при создании новой задачи
2026-01-11 21:12:26 +03:00
resetForm ( )
}
2026-01-13 20:55:44 +03:00
} , [ wishlistId , tasks , projects , restoredFromSession ] )
2026-01-11 21:12:26 +03:00
2026-01-13 16:52:08 +03:00
// С б р о с формы при размонтировании компонента или при изменении wishlistId на undefined
2026-01-12 18:58:52 +03:00
useEffect ( ( ) => {
return ( ) => {
resetForm ( )
}
2026-01-13 16:52:08 +03:00
} , [ wishlistId ] )
2026-01-12 18:58:52 +03:00
2026-01-12 17:42:51 +03:00
// Открываем форму редактирования условия, если передан editConditionIndex
useEffect ( ( ) => {
if ( editConditionIndex !== undefined && editConditionIndex !== null && unlockConditions . length > editConditionIndex ) {
setEditingConditionIndex ( editConditionIndex )
setShowConditionForm ( true )
2026-01-13 16:52:08 +03:00
} else if ( editConditionIndex === undefined || editConditionIndex === null ) {
// Закрываем форму условия, если editConditionIndex сброшен
setEditingConditionIndex ( null )
setShowConditionForm ( false )
2026-01-12 17:42:51 +03:00
}
} , [ editConditionIndex , unlockConditions ] )
2026-01-13 20:55:44 +03:00
// Восстановление состояния при возврате с создания задачи
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 ] )
2026-01-11 21:12:26 +03:00
const loadWishlist = async ( ) => {
setLoadingWishlist ( true )
try {
2026-01-13 16:52:08 +03:00
// Сначала очищаем форму, чтобы удалить старые данные
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 )
2026-01-11 21:12:26 +03:00
const response = await authFetch ( ` ${ API _URL } / ${ wishlistId } ` )
if ( ! response . ok ) {
throw new Error ( 'Ошибка загрузки желания' )
}
const data = await response . json ( )
setName ( data . name || '' )
setPrice ( data . price ? String ( data . price ) : '' )
setLink ( data . link || '' )
setImageUrl ( data . image _url || null )
2026-01-12 17:42:51 +03:00
setImageFile ( null ) // Сбрасываем imageFile при загрузке существующего желания
2026-01-11 21:12:26 +03:00
if ( data . unlock _conditions ) {
setUnlockConditions ( data . unlock _conditions . map ( ( cond , idx ) => ( {
type : cond . type ,
task _id : cond . type === 'task_completion' ? tasks . find ( t => t . name === cond . task _name ) ? . id : null ,
project _id : cond . type === 'project_points' ? projects . find ( p => p . project _name === cond . project _name ) ? . project _id : null ,
required _points : cond . required _points || null ,
2026-01-12 17:02:33 +03:00
start _date : cond . start _date || null ,
2026-01-11 21:12:26 +03:00
display _order : idx ,
} ) ) )
2026-01-13 16:52:08 +03:00
} else {
setUnlockConditions ( [ ] )
2026-01-11 21:12:26 +03:00
}
} catch ( err ) {
setError ( err . message )
} finally {
setLoadingWishlist ( false )
}
}
const resetForm = ( ) => {
setName ( '' )
setPrice ( '' )
setLink ( '' )
setImageUrl ( null )
setImageFile ( null )
setUnlockConditions ( [ ] )
setError ( '' )
2026-01-12 18:58:52 +03:00
setShowCropper ( false )
setCrop ( { x : 0 , y : 0 } )
setZoom ( 1 )
setCroppedAreaPixels ( null )
setShowConditionForm ( false )
setEditingConditionIndex ( null )
setToastMessage ( null )
2026-01-11 21:12:26 +03:00
}
// Функция для извлечения метаданных из ссылки (по нажатию кнопки)
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 {
2026-01-13 17:19:00 +03:00
// Пытаемся получить детальное сообщение о б ошибке
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' } )
2026-01-11 21:12:26 +03:00
}
} catch ( err ) {
console . error ( 'Error fetching metadata:' , err )
2026-01-13 17:19:00 +03:00
const errorMessage = err . message || 'Ошибка при загрузке информации'
setToastMessage ( { text : errorMessage , type : 'error' } )
2026-01-11 21:12:26 +03:00
} 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 = ( ) => {
2026-01-12 17:42:51 +03:00
setEditingConditionIndex ( null )
setShowConditionForm ( true )
}
const handleEditCondition = ( index ) => {
setEditingConditionIndex ( index )
2026-01-11 21:12:26 +03:00
setShowConditionForm ( true )
}
const handleConditionSubmit = ( condition ) => {
2026-01-12 17:42:51 +03:00
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 = ( ) => {
2026-01-11 21:12:26 +03:00
setShowConditionForm ( false )
2026-01-12 17:42:51 +03:00
setEditingConditionIndex ( null )
2026-01-11 21:12:26 +03:00
}
const handleRemoveCondition = ( index ) => {
setUnlockConditions ( unlockConditions . filter ( ( _ , i ) => i !== index ) )
}
2026-01-13 20:55:44 +03:00
// Обработчик для создания задачи из 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 )
}
2026-01-11 21:12:26 +03:00
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 => ( {
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 ,
2026-01-12 17:02:33 +03:00
start _date : cond . type === 'project_points' ? cond . start _date : null ,
2026-01-11 21:12:26 +03:00
} ) ) ,
}
const url = wishlistId ? ` ${ API _URL } / ${ wishlistId } ` : API _URL
const method = wishlistId ? 'PUT' : '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' } )
2026-01-12 17:42:51 +03:00
} else {
// Обновляем imageUrl после успешной загрузки
const imageData = await imageResponse . json ( )
if ( imageData . image _url ) {
setImageUrl ( imageData . image _url )
}
2026-01-11 21:12:26 +03:00
}
}
resetForm ( )
onNavigate ? . ( 'wishlist' )
} catch ( err ) {
setError ( err . message )
} finally {
setLoading ( false )
}
}
const handleCancel = ( ) => {
2026-01-12 18:58:52 +03:00
resetForm ( )
2026-01-11 21:12:26 +03:00
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" >
2026-01-12 17:42:51 +03:00
< img
src = { imageUrl }
alt = "Preview"
onClick = { ( ) => fileInputRef . current ? . click ( ) }
style = { { cursor : 'pointer' } }
title = "Нажмите, чтобы изменить"
/ >
2026-01-11 21:12:26 +03:00
< button
type = "button"
onClick = { ( ) => {
setImageUrl ( null )
setImageFile ( null )
} }
className = "remove-image-button"
>
✕
< / button >
< / div >
) }
2026-01-12 17:42:51 +03:00
< input
ref = { fileInputRef }
type = "file"
accept = "image/*"
onChange = { handleImageSelect }
className = "form-input"
style = { { display : imageUrl ? 'none' : 'block' } }
/ >
2026-01-11 21:12:26 +03:00
< / 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" >
2026-01-12 17:42:51 +03:00
< span
className = "condition-item-text"
onClick = { ( ) => handleEditCondition ( idx ) }
>
2026-01-11 21:12:26 +03:00
{ cond . type === 'task_completion'
? ` Задача: ${ tasks . find ( t => t . id === cond . task _id ) ? . name || 'Н е выбрана' } `
2026-01-12 17:02:33 +03:00
: ` Баллы: ${ cond . required _points } в ${ projects . find ( p => p . project _id === cond . project _id ) ? . project _name || 'Н е выбран' } ${ cond . start _date ? ` с ${ new Date ( cond . start _date + 'T00:00:00' ) . toLocaleDateString ( 'ru-RU' ) } ` : ' за всё время' } ` }
2026-01-11 21:12:26 +03:00
< / span >
< button
type = "button"
onClick = { ( ) => handleRemoveCondition ( idx ) }
className = "remove-condition-button"
>
✕
< / button >
< / div >
) ) }
< / div >
) }
< button
type = "button"
onClick = { handleAddCondition }
className = "add-condition-button"
>
2026-01-12 17:42:51 +03:00
Добавить цель
2026-01-11 21:12:26 +03:00
< / 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 }
2026-01-12 17:42:51 +03:00
onCancel = { handleConditionCancel }
editingCondition = { editingConditionIndex !== null ? unlockConditions [ editingConditionIndex ] : null }
2026-01-13 20:55:44 +03:00
onCreateTask = { handleCreateTaskFromCondition }
preselectedTaskId = { newTaskId }
2026-01-11 21:12:26 +03:00
/ >
) }
{ toastMessage && (
< Toast
message = { toastMessage . text }
type = { toastMessage . type }
onClose = { ( ) => setToastMessage ( null ) }
/ >
) }
< / div >
)
}
2026-01-12 17:02:33 +03:00
// Компонент селектора даты с календарём (аналогично 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 >
)
}
2026-01-13 20:55:44 +03:00
// Компонент автодополнения для выбора задачи
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 >
)
}
2026-01-12 17:42:51 +03:00
// Компонент формы цели
2026-01-13 20:55:44 +03:00
function ConditionForm ( { tasks , projects , onSubmit , onCancel , editingCondition , onCreateTask , preselectedTaskId } ) {
2026-01-12 17:42:51 +03:00
const [ type , setType ] = useState ( editingCondition ? . type || 'project_points' )
2026-01-13 20:55:44 +03:00
const [ taskId , setTaskId ] = useState ( editingCondition ? . task _id || null )
2026-01-12 17:42:51 +03:00
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
2026-01-11 21:12:26 +03:00
2026-01-13 20:55:44 +03:00
// Автоподстановка новой задачи
useEffect ( ( ) => {
if ( preselectedTaskId && ! editingCondition ) {
setType ( 'task_completion' )
setTaskId ( preselectedTaskId )
}
} , [ preselectedTaskId , editingCondition ] )
2026-01-11 21:12:26 +03:00
const handleSubmit = ( e ) => {
e . preventDefault ( )
e . stopPropagation ( ) // Предотвращаем всплытие события
// Валидация
2026-01-13 20:55:44 +03:00
if ( type === 'task_completion' && ( ! taskId || taskId === null ) ) {
2026-01-11 21:12:26 +03:00
return
}
if ( type === 'project_points' && ( ! projectId || ! requiredPoints ) ) {
return
}
const condition = {
type ,
2026-01-13 20:55:44 +03:00
task _id : type === 'task_completion' ? ( typeof taskId === 'number' ? taskId : parseInt ( taskId ) ) : null ,
2026-01-11 21:12:26 +03:00
project _id : type === 'project_points' ? parseInt ( projectId ) : null ,
required _points : type === 'project_points' ? parseFloat ( requiredPoints ) : null ,
2026-01-12 17:02:33 +03:00
start _date : type === 'project_points' && startDate ? startDate : null ,
2026-01-11 21:12:26 +03:00
}
onSubmit ( condition )
// С б р о с формы
2026-01-12 17:02:33 +03:00
setType ( 'project_points' )
2026-01-13 20:55:44 +03:00
setTaskId ( null )
2026-01-11 21:12:26 +03:00
setProjectId ( '' )
setRequiredPoints ( '' )
2026-01-12 17:02:33 +03:00
setStartDate ( '' )
2026-01-11 21:12:26 +03:00
}
return (
< div className = "condition-form-overlay" onClick = { onCancel } >
< div className = "condition-form" onClick = { ( e ) => e . stopPropagation ( ) } >
2026-01-12 17:42:51 +03:00
< h3 > { isEditing ? 'Редактировать цель' : 'Добавить цель' } < / h3 >
2026-01-11 21:12:26 +03:00
< form onSubmit = { handleSubmit } >
< div className = "form-group" >
< label > Тип условия < / label >
< select
value = { type }
onChange = { ( e ) => setType ( e . target . value ) }
className = "form-input"
>
2026-01-12 17:02:33 +03:00
< option value = "project_points" > Баллы < / option >
< option value = "task_completion" > Задача < / option >
2026-01-11 21:12:26 +03:00
< / select >
< / div >
{ type === 'task_completion' && (
< div className = "form-group" >
< label > Задача < / label >
2026-01-13 20:55:44 +03:00
< TaskAutocomplete
tasks = { tasks }
2026-01-11 21:12:26 +03:00
value = { taskId }
2026-01-13 20:55:44 +03:00
onChange = { ( id ) => setTaskId ( id ) }
onCreateTask = { onCreateTask }
preselectedTaskId = { preselectedTaskId }
/ >
2026-01-11 21:12:26 +03:00
< / 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" >
2026-01-12 17:02:33 +03:00
< label > Дата начала подсчёта < / label >
< DateSelector
value = { startDate }
onChange = { setStartDate }
placeholder = "З а всё время"
/ >
2026-01-11 21:12:26 +03:00
< / div >
< / >
) }
< div className = "form-actions" >
< button type = "button" onClick = { onCancel } className = "cancel-button" >
Отмена
< / button >
< button type = "submit" className = "submit-button" >
2026-01-12 17:42:51 +03:00
{ isEditing ? 'Сохранить' : 'Добавить' }
2026-01-11 21:12:26 +03:00
< / button >
< / div >
< / form >
< / div >
< / div >
)
}
export default WishlistForm