2026-02-08 17:01:36 +03:00
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'
2026-03-09 22:08:24 +03:00
function WishlistForm ( { onNavigate , wishlistId , editConditionIndex , newTaskId : newTaskIdProp , boardId } ) {
2026-02-08 17:01:36 +03:00
const { authFetch , user } = useAuth ( )
2026-03-09 22:08:24 +03:00
// newTaskId может прийти из props (через onNavigate) или из sessionStorage (через history.back)
const [ newTaskId ] = useState ( ( ) => {
if ( newTaskIdProp ) return newTaskIdProp
const stored = sessionStorage . getItem ( 'wishlistFormNewTaskId' )
if ( stored ) {
sessionStorage . removeItem ( 'wishlistFormNewTaskId' )
return parseInt ( stored , 10 )
}
return undefined
} )
2026-02-08 17:01:36 +03:00
const [ name , setName ] = useState ( '' )
const [ price , setPrice ] = useState ( '' )
const [ link , setLink ] = useState ( '' )
const [ imageUrl , setImageUrl ] = useState ( null )
const [ imageFile , setImageFile ] = useState ( null )
const [ imageRemoved , setImageRemoved ] = useState ( false ) // Флаг удаления фото
const [ showCropper , setShowCropper ] = useState ( false )
const [ crop , setCrop ] = useState ( { x : 0 , y : 0 } )
const [ zoom , setZoom ] = useState ( 1 )
const [ croppedAreaPixels , setCroppedAreaPixels ] = useState ( null )
const [ unlockConditions , setUnlockConditions ] = useState ( [ ] )
const [ showConditionForm , setShowConditionForm ] = useState ( false )
const [ editingConditionIndex , setEditingConditionIndex ] = useState ( null )
const [ tasks , setTasks ] = useState ( [ ] )
const [ projects , setProjects ] = useState ( [ ] )
const [ groupName , setGroupName ] = useState ( '' )
const [ groupSuggestions , setGroupSuggestions ] = useState ( [ ] )
const [ loading , setLoading ] = useState ( false )
const [ error , setError ] = useState ( '' )
const [ toastMessage , setToastMessage ] = useState ( null )
const [ loadingWishlist , setLoadingWishlist ] = useState ( false )
const [ fetchingMetadata , setFetchingMetadata ] = useState ( false )
2026-03-09 22:08:24 +03:00
const [ restoredFromSession , setRestoredFromSession ] = useState ( ( ) => {
// Инициализируем флаг сразу, чтобы loadWishlist не запустился до восстановления
return ! ! ( newTaskId && sessionStorage . getItem ( WISHLIST _FORM _STATE _KEY ) )
} )
2026-02-08 17:01:36 +03:00
const [ loadedWishlistData , setLoadedWishlistData ] = useState ( null ) // Данные желания для последующего маппинга условий
2026-02-24 17:06:44 +03:00
const [ imageUrlInput , setImageUrlInput ] = useState ( '' ) // Ссылка на картинку для загрузки по URL
const [ loadingImageFromUrl , setLoadingImageFromUrl ] = useState ( false )
2026-03-09 22:08:24 +03:00
const [ newTaskConsumed , setNewTaskConsumed ] = useState ( false ) // Флаг что newTaskId уже добавлен как цель
2026-02-08 17:01:36 +03:00
const fileInputRef = useRef ( null )
// Загрузка задач, проектов и саджестов групп
useEffect ( ( ) => {
const loadData = async ( ) => {
try {
// Загружаем задачи
const tasksResponse = await authFetch ( TASKS _API _URL )
if ( tasksResponse . ok ) {
const tasksData = await tasksResponse . json ( )
setTasks ( Array . isArray ( tasksData ) ? tasksData : [ ] )
}
// Загружаем проекты (нужны для ConditionForm)
const projectsResponse = await authFetch ( PROJECTS _API _URL )
if ( projectsResponse . ok ) {
const projectsData = await projectsResponse . json ( )
setProjects ( Array . isArray ( projectsData ) ? projectsData : [ ] )
}
// Загружаем саджесты групп
const groupsResponse = await authFetch ( '/api/group-suggestions' )
if ( groupsResponse . ok ) {
const groupsData = await groupsResponse . json ( )
setGroupSuggestions ( Array . isArray ( groupsData ) ? groupsData : [ ] )
}
} catch ( err ) {
console . error ( 'Error loading data:' , err )
}
}
loadData ( )
} , [ authFetch ] )
// Загрузка желания при редактировании или с б р о с формы при создании
useEffect ( ( ) => {
// Пропускаем загрузку, если состояние было восстановлено из sessionStorage
if ( restoredFromSession ) {
console . log ( '[WishlistForm] Skipping loadWishlist - restored from session' )
return
}
if ( wishlistId !== undefined && wishlistId !== null ) {
// Загружаем желание независимо от наличия задач и проектов
loadWishlist ( )
} else if ( wishlistId === undefined || wishlistId === null ) {
// Сбрасываем форму при создании новой задачи
resetForm ( )
setLoadedWishlistData ( null )
}
} , [ wishlistId , restoredFromSession ] )
// Обновляем маппинг условий после загрузки задач и проектов
useEffect ( ( ) => {
// Если есть загруженные данные желания, но маппинг еще не выполнен,
// обновляем условия с правильным маппингом
if ( loadedWishlistData && tasks . length > 0 && projects . length > 0 ) {
const data = loadedWishlistData
setName ( data . name || '' )
setPrice ( data . price ? String ( data . price ) : '' )
setLink ( data . link || '' )
setImageUrl ( data . image _url || null )
setGroupName ( data . group _name || '' )
if ( data . unlock _conditions ) {
setUnlockConditions ( data . unlock _conditions . map ( ( cond , idx ) => ( {
id : cond . id || null ,
type : cond . type ,
task _id : cond . type === 'task_completion' ? ( cond . task _id || tasks . find ( t => t . name === cond . task _name ) ? . id ) : null ,
task _name : cond . task _name || null ,
project _id : cond . type === 'project_points' ? ( cond . project _id || projects . find ( p => p . project _name === cond . project _name ) ? . project _id ) : null ,
project _name : cond . project _name || null ,
required _points : cond . required _points || null ,
start _date : cond . start _date || null ,
display _order : idx ,
user _id : cond . user _id || null ,
weeks _text : cond . weeks _text || null ,
} ) ) )
} else {
setUnlockConditions ( [ ] )
}
setLoadedWishlistData ( null ) // Очищаем после применения
}
} , [ tasks , projects , loadedWishlistData ] )
// С б р о с формы при размонтировании компонента или при изменении wishlistId на undefined
useEffect ( ( ) => {
return ( ) => {
resetForm ( )
}
} , [ wishlistId ] )
// Открываем форму редактирования условия, если передан editConditionIndex
useEffect ( ( ) => {
if ( editConditionIndex !== undefined && editConditionIndex !== null && unlockConditions . length > editConditionIndex ) {
setEditingConditionIndex ( editConditionIndex )
setShowConditionForm ( true )
} else if ( editConditionIndex === undefined || editConditionIndex === null ) {
// Закрываем форму условия, если editConditionIndex сброшен
setEditingConditionIndex ( null )
setShowConditionForm ( false )
}
} , [ editConditionIndex , unlockConditions ] )
2026-03-09 22:08:24 +03:00
// Обработка кнопки "назад" для диалога ConditionForm
const showConditionFormRef = useRef ( false )
const conditionClosedByPopStateRef = useRef ( false )
showConditionFormRef . current = showConditionForm
useEffect ( ( ) => {
const handlePopState = ( ) => {
if ( showConditionFormRef . current ) {
// Закрываем диалог — popstate уже убрал запись из стека
conditionClosedByPopStateRef . current = true
setShowConditionForm ( false )
setEditingConditionIndex ( null )
}
}
window . addEventListener ( 'popstate' , handlePopState )
return ( ) => window . removeEventListener ( 'popstate' , handlePopState )
} , [ ] )
2026-02-08 17:01:36 +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 )
setImageRemoved ( false ) // Сбрасываем флаг удаления при восстановлении
// Восстанавливаем условия и автоматически добавляем новую задачу
const restoredConditions = state . unlockConditions || [ ]
console . log ( '[WishlistForm] Restored conditions:' , restoredConditions )
2026-03-09 22:08:24 +03:00
// Устанавливаем флаг синхронно, чтобы loadWishlist не перезаписал восстановленное состояние
setRestoredFromSession ( true )
2026-02-08 17:01:36 +03:00
// Перезагружаем задачи, чтобы новая задача была в списке
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 : [ ] )
2026-03-09 22:08:24 +03:00
2026-02-08 17:01:36 +03:00
// Автоматически добавляем цель с новой задачей
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 )
2026-03-09 22:08:24 +03:00
2026-02-08 17:01:36 +03:00
// Если редактировали существующее условие, заменяем е г о
if ( state . editingConditionIndex !== null && state . editingConditionIndex !== undefined ) {
console . log ( '[WishlistForm] Replacing existing condition at index:' , state . editingConditionIndex )
2026-03-09 22:08:24 +03:00
const updatedConditions = restoredConditions . map ( ( cond , idx ) =>
2026-02-08 17:01:36 +03:00
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 )
}
2026-03-09 22:08:24 +03:00
setNewTaskConsumed ( true )
2026-02-08 17:01:36 +03:00
} else {
setUnlockConditions ( restoredConditions )
}
}
} catch ( err ) {
console . error ( '[WishlistForm] Error reloading tasks:' , err )
setUnlockConditions ( restoredConditions )
}
}
reloadTasks ( )
// Очищаем sessionStorage
sessionStorage . removeItem ( WISHLIST _FORM _STATE _KEY )
console . log ( '[WishlistForm] SessionStorage cleared' )
} catch ( e ) {
console . error ( '[WishlistForm] Error restoring wishlist form state:' , e )
sessionStorage . removeItem ( WISHLIST _FORM _STATE _KEY )
}
}
} , [ newTaskId , authFetch ] )
const loadWishlist = async ( ) => {
setLoadingWishlist ( true )
try {
// Сначала очищаем форму, чтобы удалить старые данные
setName ( '' )
setPrice ( '' )
setLink ( '' )
setImageUrl ( null )
setImageFile ( null )
setImageRemoved ( false )
setUnlockConditions ( [ ] )
setError ( '' )
setShowCropper ( false )
setCrop ( { x : 0 , y : 0 } )
setZoom ( 1 )
setCroppedAreaPixels ( null )
2026-02-24 17:06:44 +03:00
setImageUrlInput ( '' )
setLoadingImageFromUrl ( false )
2026-02-08 17:01:36 +03:00
setShowConditionForm ( false )
setEditingConditionIndex ( null )
setToastMessage ( null )
const response = await authFetch ( ` ${ API _URL } / ${ wishlistId } ` )
if ( ! response . ok ) {
throw new Error ( 'Ошибка загрузки желания' )
}
const data = await response . json ( )
// Если задачи и проекты уже загружены, применяем данные сразу
// Иначе сохраняем данные для последующего применения
if ( tasks . length > 0 && projects . length > 0 ) {
setName ( data . name || '' )
setPrice ( data . price ? String ( data . price ) : '' )
setLink ( data . link || '' )
setImageUrl ( data . image _url || null )
setImageFile ( null ) // Сбрасываем imageFile при загрузке существующего желания
setImageRemoved ( false ) // Сбрасываем флаг удаления при загрузке
setGroupName ( data . group _name || '' )
if ( data . unlock _conditions ) {
setUnlockConditions ( data . unlock _conditions . map ( ( cond , idx ) => ( {
id : cond . id || null ,
type : cond . type ,
task _id : cond . type === 'task_completion' ? ( cond . task _id || tasks . find ( t => t . name === cond . task _name ) ? . id ) : null ,
task _name : cond . task _name || null ,
project _id : cond . type === 'project_points' ? ( cond . project _id || projects . find ( p => p . project _name === cond . project _name ) ? . project _id ) : null ,
project _name : cond . project _name || null ,
required _points : cond . required _points || null ,
start _date : cond . start _date || null ,
display _order : idx ,
user _id : cond . user _id || null ,
weeks _text : cond . weeks _text || null ,
} ) ) )
} else {
setUnlockConditions ( [ ] )
}
} else {
// Сохраняем данные для последующего применения после загрузки задач и проектов
setLoadedWishlistData ( data )
// Применяем базовые данные сразу
setName ( data . name || '' )
setPrice ( data . price ? String ( data . price ) : '' )
setLink ( data . link || '' )
setImageUrl ( data . image _url || null )
setImageFile ( null )
setImageRemoved ( false ) // Сбрасываем флаг удаления при загрузке
setGroupName ( data . group _name || '' )
}
} catch ( err ) {
setError ( err . message )
} finally {
setLoadingWishlist ( false )
}
}
const resetForm = ( ) => {
setName ( '' )
setPrice ( '' )
setLink ( '' )
setImageUrl ( null )
setImageFile ( null )
setImageRemoved ( false )
2026-02-24 17:06:44 +03:00
setImageUrlInput ( '' )
setLoadingImageFromUrl ( false )
2026-02-08 17:01:36 +03:00
setUnlockConditions ( [ ] )
setGroupName ( '' )
setError ( '' )
setShowCropper ( false )
setCrop ( { x : 0 , y : 0 } )
setZoom ( 1 )
setCroppedAreaPixels ( null )
setShowConditionForm ( false )
setEditingConditionIndex ( null )
setToastMessage ( null )
}
// Функция для извлечения метаданных из ссылки (по нажатию кнопки)
const fetchLinkMetadata = useCallback ( async ( ) => {
if ( ! link || ! link . trim ( ) ) {
setToastMessage ( { text : 'Введите ссылку' , type : 'error' } )
return
}
// Проверяем валидность 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 )
setImageRemoved ( false ) // Сбрасываем флаг удаления при загрузке из метаданных
setShowCropper ( true )
}
reader . readAsDataURL ( blob )
loaded = true
}
}
} catch ( imgErr ) {
console . error ( 'Error loading image from URL:' , imgErr )
}
}
if ( loaded ) {
setToastMessage ( { text : 'Информация загружена из ссылки' , type : 'success' } )
} else {
setToastMessage ( { text : 'Н е удалось найти информацию на странице' , type : 'warning' } )
}
} else {
// Пытаемся получить детальное сообщение о б ошибке
let errorMessage = 'Н е удалось загрузить информацию'
try {
const errorData = await response . json ( )
errorMessage = errorData . message || errorData . error || errorMessage
} catch ( e ) {
const text = await response . text ( ) . catch ( ( ) => '' )
if ( text ) {
try {
const parsed = JSON . parse ( text )
errorMessage = parsed . message || parsed . error || errorMessage
} catch {
// Если не JSON, используем текст как есть (но обрезаем до разумной длины)
if ( text . length < 200 ) {
errorMessage = text
}
}
}
}
setToastMessage ( { text : errorMessage , type : 'error' } )
}
} catch ( err ) {
console . error ( 'Error fetching metadata:' , err )
const errorMessage = err . message || 'Ошибка при загрузке информации'
setToastMessage ( { text : errorMessage , type : 'error' } )
} finally {
setFetchingMetadata ( false )
}
} , [ authFetch , link , name , price , imageUrl ] )
const handleImageSelect = ( e ) => {
const file = e . target . files ? . [ 0 ]
if ( ! file ) return
if ( file . size > 5 * 1024 * 1024 ) {
setToastMessage ( { text : 'Файл слишком большой (максимум 5MB)' , type : 'error' } )
return
}
const reader = new FileReader ( )
reader . onload = ( ) => {
setImageFile ( file )
setImageUrl ( reader . result )
setImageRemoved ( false ) // Сбрасываем флаг удаления при выборе нового фото
setShowCropper ( true )
}
reader . readAsDataURL ( file )
}
2026-02-24 17:06:44 +03:00
// Загрузка картинки по ссылке с последующим кропом
const loadImageFromUrl = async ( ) => {
const url = imageUrlInput ? . trim ( )
if ( ! url ) {
setToastMessage ( { text : 'Введите ссылку на картинку' , type : 'error' } )
return
}
try {
new URL ( url )
} catch {
setToastMessage ( { text : 'Некорректная ссылка на картинку' , type : 'error' } )
return
}
setLoadingImageFromUrl ( true )
try {
const proxyUrl = ` ${ API _URL } /proxy-image?url= ${ encodeURIComponent ( url ) } `
const imgResponse = await authFetch ( proxyUrl )
if ( ! imgResponse . ok ) {
const errData = await imgResponse . json ( ) . catch ( ( ) => ( { } ) )
throw new Error ( errData . message || errData . error || 'Н е удалось загрузить картинку' )
}
const blob = await imgResponse . blob ( )
if ( blob . size > 5 * 1024 * 1024 ) {
setToastMessage ( { text : 'Картинка слишком большая (максимум 5MB)' , type : 'error' } )
return
}
if ( ! blob . type . startsWith ( 'image/' ) ) {
setToastMessage ( { text : 'По ссылке не изображение' , type : 'error' } )
return
}
const reader = new FileReader ( )
reader . onload = ( ) => {
setImageUrl ( reader . result )
setImageFile ( blob )
setImageRemoved ( false )
setImageUrlInput ( '' )
setShowCropper ( true )
}
reader . readAsDataURL ( blob )
} catch ( err ) {
setToastMessage ( { text : err . message || 'Ошибка при загрузке картинки по ссылке' , type : 'error' } )
} finally {
setLoadingImageFromUrl ( false )
}
}
2026-02-08 17:01:36 +03:00
const onCropComplete = ( croppedArea , croppedAreaPixels ) => {
setCroppedAreaPixels ( croppedAreaPixels )
}
const createImage = ( url ) => {
return new Promise ( ( resolve , reject ) => {
const image = new Image ( )
image . addEventListener ( 'load' , ( ) => resolve ( image ) )
image . addEventListener ( 'error' , ( error ) => reject ( error ) )
image . src = url
} )
}
const getCroppedImg = async ( imageSrc , pixelCrop ) => {
const image = await createImage ( imageSrc )
const canvas = document . createElement ( 'canvas' )
const ctx = canvas . getContext ( '2d' )
canvas . width = pixelCrop . width
canvas . height = pixelCrop . height
ctx . drawImage (
image ,
pixelCrop . x ,
pixelCrop . y ,
pixelCrop . width ,
pixelCrop . height ,
0 ,
0 ,
pixelCrop . width ,
pixelCrop . height
)
return new Promise ( ( resolve ) => {
canvas . toBlob ( resolve , 'image/jpeg' , 0.95 )
} )
}
const handleCropSave = async ( ) => {
if ( ! imageUrl || ! croppedAreaPixels ) return
try {
const croppedImage = await getCroppedImg ( imageUrl , croppedAreaPixels )
const reader = new FileReader ( )
reader . onload = ( ) => {
setImageUrl ( reader . result )
setImageFile ( croppedImage )
setImageRemoved ( false ) // Сбрасываем флаг удаления при сохранении обрезки
setShowCropper ( false )
}
reader . readAsDataURL ( croppedImage )
} catch ( err ) {
setToastMessage ( { text : 'Ошибка при обрезке изображения' , type : 'error' } )
}
}
2026-03-09 22:08:24 +03:00
const openConditionForm = ( ) => {
setShowConditionForm ( true )
conditionClosedByPopStateRef . current = false
window . history . pushState ( { conditionForm : true } , '' , window . location . href )
}
const closeConditionForm = ( ) => {
setShowConditionForm ( false )
setEditingConditionIndex ( null )
// Если закрытие через popstate — запись уже убрана, не делаем back
if ( ! conditionClosedByPopStateRef . current ) {
window . history . back ( )
}
conditionClosedByPopStateRef . current = false
}
2026-02-08 17:01:36 +03:00
const handleAddCondition = ( ) => {
setEditingConditionIndex ( null )
2026-03-09 22:08:24 +03:00
openConditionForm ( )
2026-02-08 17:01:36 +03:00
}
const handleEditCondition = ( index ) => {
const condition = unlockConditions [ index ]
// Проверяем, что условие принадлежит текущему пользователю
if ( condition . user _id && condition . user _id !== user ? . id ) {
setToastMessage ( { text : 'Нельзя редактировать чужие цели' , type : 'error' } )
return
}
setEditingConditionIndex ( index )
2026-03-09 22:08:24 +03:00
openConditionForm ( )
2026-02-08 17:01:36 +03:00
}
const handleConditionSubmit = ( condition ) => {
if ( editingConditionIndex !== null ) {
// Редактирование существующего условия
2026-03-09 22:08:24 +03:00
setUnlockConditions ( prev => prev . map ( ( cond , idx ) =>
2026-02-08 17:01:36 +03:00
idx === editingConditionIndex ? { ... condition , display _order : idx } : cond
) )
} else {
// Добавление нового условия
setUnlockConditions ( [ ... unlockConditions , { ... condition , display _order : unlockConditions . length } ] )
}
2026-03-09 22:08:24 +03:00
closeConditionForm ( )
2026-02-08 17:01:36 +03:00
}
const handleConditionCancel = ( ) => {
2026-03-09 22:08:24 +03:00
closeConditionForm ( )
2026-02-08 17:01:36 +03:00
}
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 = ( ) => {
2026-03-09 22:08:24 +03:00
// Закрываем диалог цели перед переходом
setShowConditionForm ( false )
2026-02-08 17:01:36 +03:00
// Сохранить текущее состояние формы
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 ) )
2026-03-09 22:08:24 +03:00
2026-02-08 17:01:36 +03:00
// Навигация на форму создания задачи
const navParams = {
returnTo : 'wishlist-form' ,
returnWishlistId : wishlistId ,
}
console . log ( '[WishlistForm] Navigation params:' , navParams )
onNavigate ? . ( 'task-form' , navParams )
}
const handleSubmit = async ( e ) => {
e . preventDefault ( )
setError ( '' )
setLoading ( true )
if ( ! name . trim ( ) ) {
setError ( 'Название обязательно' )
setLoading ( false )
return
}
try {
const payload = {
name : name . trim ( ) ,
price : price ? parseFloat ( price ) : null ,
link : link . trim ( ) || null ,
group _name : groupName . trim ( ) || null ,
unlock _conditions : unlockConditions . map ( cond => ( {
id : cond . id || null ,
type : cond . type ,
task _id : cond . type === 'task_completion' ? cond . task _id : null ,
project _id : cond . type === 'project_points' ? cond . project _id : null ,
required _points : cond . type === 'project_points' ? parseFloat ( cond . required _points ) : null ,
start _date : cond . type === 'project_points' ? cond . start _date : null ,
} ) ) ,
}
let url , method
if ( wishlistId ) {
// Редактирование существующего желания
url = ` ${ API _URL } / ${ wishlistId } `
method = 'PUT'
} else {
// Создание нового желания
if ( boardId ) {
// Создание на доске
url = ` /api/wishlist/boards/ ${ boardId } /items `
} else {
// Старый API для обратной совместимости
url = API _URL
}
method = 'POST'
}
const response = await authFetch ( url , {
method ,
headers : {
'Content-Type' : 'application/json' ,
} ,
body : JSON . stringify ( payload ) ,
} )
if ( ! response . ok ) {
let errorMessage = 'Ошибка при сохранении'
try {
const errorData = await response . json ( )
errorMessage = errorData . message || errorData . error || errorMessage
} catch ( e ) {
const text = await response . text ( ) . catch ( ( ) => '' )
if ( text ) errorMessage = text
}
throw new Error ( errorMessage )
}
const savedItem = await response . json ( )
const itemId = savedItem . id || wishlistId
// Удаляем картинку если она была удалена пользователем
if ( imageRemoved && itemId ) {
const deleteResponse = await authFetch ( ` ${ API _URL } / ${ itemId } /image ` , {
method : 'DELETE' ,
} )
if ( ! deleteResponse . ok ) {
setToastMessage ( { text : 'Желание сохранено, но ошибка при удалении картинки' , type : 'warning' } )
}
}
// Загружаем картинку если есть новое фото
if ( imageFile && itemId && ! imageRemoved ) {
const formData = new FormData ( )
formData . append ( 'image' , imageFile )
const imageResponse = await authFetch ( ` ${ API _URL } / ${ itemId } /image ` , {
method : 'POST' ,
body : formData ,
} )
if ( ! imageResponse . ok ) {
setToastMessage ( { text : 'Желание сохранено, но ошибка при загрузке картинки' , type : 'warning' } )
} else {
// Обновляем imageUrl после успешной загрузки
const imageData = await imageResponse . json ( )
if ( imageData . image _url ) {
setImageUrl ( imageData . image _url )
}
}
}
resetForm ( )
2026-03-09 21:42:18 +03:00
// Возврат назад по стеку истории
window . history . back ( )
2026-02-08 17:01:36 +03:00
} catch ( err ) {
setError ( err . message )
} finally {
setLoading ( false )
}
}
const handleCancel = ( ) => {
resetForm ( )
window . history . back ( )
}
return (
< div className = "wishlist-form" >
< button className = "close-x-button" onClick = { handleCancel } >
✕
< / button >
{ loadingWishlist ? (
< div className = "fixed inset-0 flex justify-center items-center" >
< div className = "flex flex-col items-center" >
< div className = "w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4" > < / div >
< div className = "text-gray-600 font-medium" > Загрузка ... < / div >
< / div >
< / div >
) : (
< >
< h2 > { wishlistId ? 'Редактировать желание' : 'Новое желание' } < / h2 >
< form 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 htmlFor = "group" > Группа < / label >
< GroupAutocomplete
suggestions = { groupSuggestions }
value = { groupName }
onChange = { setGroupName }
/ >
< / div >
< div className = "form-group" >
< label > Картинка < / label >
{ imageUrl && ! showCropper && (
< div className = "image-preview" >
< img
src = { imageUrl }
alt = "Preview"
onClick = { ( ) => fileInputRef . current ? . click ( ) }
style = { { cursor : 'pointer' } }
title = "Нажмите, чтобы изменить"
/ >
< button
type = "button"
onClick = { ( ) => {
setImageUrl ( null )
setImageFile ( null )
setImageRemoved ( true ) // Устанавливаем флаг удаления
// Сбрасываем file input, чтобы можно было выбрать новое фото
if ( fileInputRef . current ) {
fileInputRef . current . value = ''
}
} }
className = "remove-image-button"
>
✕
< / button >
< / div >
) }
2026-02-24 17:06:44 +03:00
{ ! imageUrl && (
< >
< div className = "image-url-row" >
< span className = "image-url-label" > Файл : < / span >
< label className = "file-input-label" >
< input
ref = { fileInputRef }
type = "file"
accept = "image/*"
onChange = { handleImageSelect }
className = "file-input-hidden"
/ >
< span className = "file-input-button" > Выбрать < / span >
< / label >
< / div >
< div className = "image-url-row" >
< span className = "image-url-label" > Ссылка : < / span >
< input
type = "url"
value = { imageUrlInput }
onChange = { ( e ) => setImageUrlInput ( e . target . value ) }
placeholder = "https://..."
className = "form-input image-url-input"
disabled = { loadingImageFromUrl }
/ >
< button
type = "button"
className = "image-url-load-button"
onClick = { loadImageFromUrl }
disabled = { loadingImageFromUrl || ! imageUrlInput . trim ( ) }
title = "Загрузить картинку по ссылке и обрезать"
>
{ loadingImageFromUrl ? (
< div className = "mini-spinner" > < / div >
) : (
< svg width = "20" height = "20" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" >
< path d = "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" / >
< polyline points = "7 10 12 15 17 10" / >
< line x1 = "12" y1 = "15" x2 = "12" y2 = "3" / >
< / svg >
) }
< / button >
< / div >
< / >
) }
2026-02-08 17:01:36 +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 ) => {
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 }
2026-03-09 22:08:24 +03:00
preselectedTaskId = { newTaskConsumed ? undefined : newTaskId }
2026-02-08 17:01:36 +03:00
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 GroupAutocomplete ( { suggestions , value , onChange } ) {
const [ inputValue , setInputValue ] = useState ( '' )
const [ isOpen , setIsOpen ] = useState ( false )
const [ highlightedIndex , setHighlightedIndex ] = useState ( - 1 )
const wrapperRef = useRef ( null )
const inputRef = useRef ( null )
// При изменении value - обновить inputValue
useEffect ( ( ) => {
setInputValue ( value || '' )
} , [ value ] )
// Фильтрация саджестов
const filteredSuggestions = inputValue . trim ( )
? suggestions . filter ( group =>
group . toLowerCase ( ) . includes ( inputValue . toLowerCase ( ) )
)
: suggestions
// Закрытие при клике снаружи
useEffect ( ( ) => {
const handleClickOutside = ( e ) => {
if ( wrapperRef . current && ! wrapperRef . current . contains ( e . target ) ) {
setIsOpen ( false )
// Восстанавливаем значение
setInputValue ( value || '' )
}
}
document . addEventListener ( 'mousedown' , handleClickOutside )
return ( ) => document . removeEventListener ( 'mousedown' , handleClickOutside )
} , [ value ] )
const handleInputChange = ( e ) => {
const newValue = e . target . value
setInputValue ( newValue )
setIsOpen ( true )
setHighlightedIndex ( - 1 )
onChange ( newValue )
}
const handleSelectGroup = ( group ) => {
onChange ( group )
setInputValue ( group )
setIsOpen ( false )
setHighlightedIndex ( - 1 )
}
const handleKeyDown = ( e ) => {
if ( ! isOpen ) {
if ( e . key === 'ArrowDown' || e . key === 'Enter' ) {
setIsOpen ( true )
e . preventDefault ( )
}
return
}
switch ( e . key ) {
case 'ArrowDown' :
e . preventDefault ( )
setHighlightedIndex ( prev =>
prev < filteredSuggestions . length - 1 ? prev + 1 : prev
)
break
case 'ArrowUp' :
e . preventDefault ( )
setHighlightedIndex ( prev => prev > 0 ? prev - 1 : - 1 )
break
case 'Enter' :
e . preventDefault ( )
if ( highlightedIndex >= 0 && filteredSuggestions [ highlightedIndex ] ) {
handleSelectGroup ( filteredSuggestions [ highlightedIndex ] )
}
break
case 'Escape' :
setIsOpen ( false )
setInputValue ( value || '' )
break
}
}
const handleFocus = ( ) => {
setIsOpen ( true )
}
return (
< div className = "group-autocomplete" ref = { wrapperRef } >
< div className = "group-autocomplete-input-wrapper" >
< input
ref = { inputRef }
type = "text"
value = { inputValue }
onChange = { handleInputChange }
onFocus = { handleFocus }
onKeyDown = { handleKeyDown }
placeholder = "Введите название группы..."
className = "form-input"
autoComplete = "off"
/ >
{ inputValue && (
< button
type = "button"
onClick = { ( ) => {
setInputValue ( '' )
onChange ( '' )
inputRef . current ? . focus ( )
} }
className = "group-autocomplete-clear"
>
✕
< / button >
) }
< / div >
{ isOpen && filteredSuggestions . length > 0 && (
< div className = "group-autocomplete-dropdown" >
{ filteredSuggestions . map ( ( group , index ) => (
< div
key = { group }
className = { ` group-autocomplete-item ${
highlightedIndex === index ? 'highlighted' : ''
} ` }
onClick = { ( ) => handleSelectGroup ( group ) }
onMouseEnter = { ( ) => setHighlightedIndex ( index ) }
>
{ group }
< / div >
) ) }
< / div >
) }
< / div >
)
}
// Компонент автодополнения для выбора задачи
function TaskAutocomplete ( { tasks , value , onChange , onCreateTask , preselectedTaskId } ) {
const [ inputValue , setInputValue ] = useState ( '' )
const [ isOpen , setIsOpen ] = useState ( false )
const [ highlightedIndex , setHighlightedIndex ] = useState ( - 1 )
const wrapperRef = useRef ( null )
const inputRef = useRef ( null )
// Найти выбранную задачу по ID
const selectedTask = tasks . find ( t => t . id === value )
// При изменении selectedTask или value - обновить inputValue
useEffect ( ( ) => {
if ( selectedTask ) {
setInputValue ( selectedTask . name )
} else if ( ! value ) {
setInputValue ( '' )
}
} , [ selectedTask , value ] )
// При preselectedTaskId автоматически выбрать задачу
useEffect ( ( ) => {
if ( preselectedTaskId && ! value && tasks . length > 0 ) {
const task = tasks . find ( t => t . id === preselectedTaskId )
if ( task && value !== preselectedTaskId ) {
onChange ( preselectedTaskId )
setInputValue ( task . name )
}
}
} , [ preselectedTaskId , tasks . length , value , onChange ] )
// Фильтрация задач
const filteredTasks = inputValue . trim ( )
? tasks . filter ( task =>
task . name . toLowerCase ( ) . includes ( inputValue . toLowerCase ( ) )
)
: tasks
// Закрытие при клике снаружи
useEffect ( ( ) => {
const handleClickOutside = ( e ) => {
if ( wrapperRef . current && ! wrapperRef . current . contains ( e . target ) ) {
setIsOpen ( false )
// Восстанавливаем название выбранной задачи
if ( selectedTask ) {
setInputValue ( selectedTask . name )
} else if ( ! value ) {
setInputValue ( '' )
}
}
}
document . addEventListener ( 'mousedown' , handleClickOutside )
return ( ) => document . removeEventListener ( 'mousedown' , handleClickOutside )
} , [ selectedTask , value ] )
const handleInputChange = ( e ) => {
setInputValue ( e . target . value )
setIsOpen ( true )
setHighlightedIndex ( - 1 )
// Сбрасываем выбор, если пользователь изменил текст
if ( selectedTask && e . target . value !== selectedTask . name ) {
onChange ( null )
}
}
const handleSelectTask = ( task ) => {
onChange ( task . id )
setInputValue ( task . name )
setIsOpen ( false )
setHighlightedIndex ( - 1 )
}
const handleKeyDown = ( e ) => {
if ( ! isOpen ) {
if ( e . key === 'ArrowDown' || e . key === 'Enter' ) {
setIsOpen ( true )
e . preventDefault ( )
}
return
}
switch ( e . key ) {
case 'ArrowDown' :
e . preventDefault ( )
setHighlightedIndex ( prev =>
prev < filteredTasks . length - 1 ? prev + 1 : prev
)
break
case 'ArrowUp' :
e . preventDefault ( )
setHighlightedIndex ( prev => prev > 0 ? prev - 1 : - 1 )
break
case 'Enter' :
e . preventDefault ( )
if ( highlightedIndex >= 0 && filteredTasks [ highlightedIndex ] ) {
handleSelectTask ( filteredTasks [ highlightedIndex ] )
}
break
case 'Escape' :
setIsOpen ( false )
if ( selectedTask ) {
setInputValue ( selectedTask . name )
} else {
setInputValue ( '' )
}
break
}
}
const handleFocus = ( ) => {
setIsOpen ( true )
}
return (
< div className = "task-autocomplete" ref = { wrapperRef } >
< div className = "task-autocomplete-row" >
< div className = "task-autocomplete-input-wrapper" >
< input
ref = { inputRef }
type = "text"
value = { inputValue }
onChange = { handleInputChange }
onFocus = { handleFocus }
onKeyDown = { handleKeyDown }
placeholder = "Начните вводить название..."
className = "task-autocomplete-input"
autoComplete = "off"
/ >
{ inputValue && (
< button
type = "button"
onClick = { ( ) => {
setInputValue ( '' )
onChange ( null )
inputRef . current ? . focus ( )
} }
className = "task-autocomplete-clear"
>
✕
< / button >
) }
< / div >
< button
type = "button"
onClick = { onCreateTask }
className = "create-task-button"
title = "Создать новую задачу"
>
< svg width = "20" height = "20" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" >
< line x1 = "12" y1 = "5" x2 = "12" y2 = "19" > < / line >
< line x1 = "5" y1 = "12" x2 = "19" y2 = "12" > < / line >
< / svg >
< / button >
< / div >
{ isOpen && (
< div className = "task-autocomplete-dropdown" >
{ filteredTasks . length === 0 ? (
< div className = "task-autocomplete-empty" >
{ inputValue ? 'Задачи не найдены' : 'Нет доступных задач' }
< / div >
) : (
filteredTasks . map ( ( task , index ) => (
< div
key = { task . id }
className = { ` task-autocomplete-item ${
value === task . id ? 'selected' : ''
} $ { highlightedIndex === index ? 'highlighted' : '' } ` }
onClick = { ( ) => handleSelectTask ( task ) }
onMouseEnter = { ( ) => setHighlightedIndex ( index ) }
>
{ task . name }
< / div >
) )
) }
< / div >
) }
< / div >
)
}
// Компонент формы цели
function ConditionForm ( { tasks , projects , onSubmit , onCancel , editingCondition , onCreateTask , preselectedTaskId , authFetch } ) {
const [ type , setType ] = useState ( editingCondition ? . type || 'project_points' )
const [ taskId , setTaskId ] = useState ( editingCondition ? . task _id || null )
const [ projectId , setProjectId ] = useState ( editingCondition ? . project _id ? . toString ( ) || '' )
const [ requiredPoints , setRequiredPoints ] = useState ( editingCondition ? . required _points ? . toString ( ) || '' )
const [ startDate , setStartDate ] = useState ( editingCondition ? . start _date || '' )
2026-03-05 12:59:31 +03:00
const [ calculatedWeeksText , setCalculatedWeeksText ] = useState (
editingCondition ? . type === 'project_points' ? ( editingCondition ? . weeks _text ? ? null ) : null
)
2026-02-08 17:01:36 +03:00
const isEditing = editingCondition !== null
2026-03-05 12:59:31 +03:00
// Показываем срок разблокировки из редактируемого условия до прихода ответа API
useEffect ( ( ) => {
if ( editingCondition ? . type === 'project_points' && editingCondition ? . weeks _text ) {
setCalculatedWeeksText ( editingCondition . weeks _text )
}
} , [ editingCondition ? . id , editingCondition ? . type , editingCondition ? . weeks _text ] )
2026-02-08 17:01:36 +03:00
// Автоподстановка новой задачи
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 ,
2026-03-05 12:59:31 +03:00
... ( type === 'project_points' && {
weeks _text : calculatedWeeksText || editingCondition ? . weeks _text || null ,
} ) ,
... ( editingCondition ? . id != null && { id : editingCondition . id } ) ,
... ( editingCondition ? . user _id != null && { user _id : editingCondition . user _id } ) ,
2026-02-08 17:01:36 +03:00
}
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