Оптимизация wishlist: раздельные запросы и копирование
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m14s

This commit is contained in:
poignatov
2026-01-13 20:55:44 +03:00
parent db3b2640a8
commit ce7e0e584a
8 changed files with 943 additions and 185 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "play-life-web",
"version": "3.11.0",
"version": "3.12.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -615,9 +615,9 @@ function AppContent() {
{
// Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров
// task-form может иметь taskId (редактирование) или wishlistId (создание из желания)
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined
const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined
// task-form может иметь taskId (редактирование), wishlistId (создание из желания), или returnTo (возврат после создания)
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === undefined
const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined && params.newTaskId === undefined
if (isTaskFormWithNoParams || isWishlistFormWithNoParams) {
setTabParams({})
if (isNewTabMain) {
@@ -848,6 +848,8 @@ function AppContent() {
onNavigate={handleNavigate}
taskId={tabParams.taskId}
wishlistId={tabParams.wishlistId}
returnTo={tabParams.returnTo}
returnWishlistId={tabParams.returnWishlistId}
/>
</div>
)}
@@ -857,6 +859,7 @@ function AppContent() {
<Wishlist
onNavigate={handleNavigate}
refreshTrigger={wishlistRefreshTrigger}
isActive={activeTab === 'wishlist'}
/>
</div>
)}
@@ -864,10 +867,11 @@ function AppContent() {
{loadedTabs['wishlist-form'] && (
<div className={activeTab === 'wishlist-form' ? 'block' : 'hidden'}>
<WishlistForm
key={`${tabParams.wishlistId || 'new'}-${tabParams.editConditionIndex ?? ''}`}
key={`${tabParams.wishlistId || 'new'}-${tabParams.editConditionIndex ?? ''}-${tabParams.newTaskId ?? ''}`}
onNavigate={handleNavigate}
wishlistId={tabParams.wishlistId}
editConditionIndex={tabParams.editConditionIndex}
newTaskId={tabParams.newTaskId}
/>
</div>
)}

View File

@@ -6,7 +6,7 @@ import './TaskForm.css'
const API_URL = '/api/tasks'
const PROJECTS_API_URL = '/projects'
function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = false }) {
function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = false, returnTo, returnWishlistId }) {
const { authFetch } = useAuth()
const [name, setName] = useState('')
const [progressionBase, setProgressionBase] = useState('')
@@ -678,11 +678,28 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
throw new Error(errorMessage)
}
// Получаем сохранённую задачу из ответа
const savedTask = await response.json()
// Сервер возвращает Task напрямую, поэтому используем savedTask.id
const newTaskId = savedTask.id
console.log('[TaskForm] Task saved, returnTo:', returnTo, 'returnWishlistId:', returnWishlistId, 'newTaskId:', newTaskId)
// Очищаем форму после успешного сохранения
resetForm()
// Возвращаемся к списку задач
onNavigate?.('tasks')
// Если был returnTo, возвращаемся на форму желания с ID новой задачи
if (returnTo === 'wishlist-form') {
console.log('[TaskForm] Navigating back to wishlist-form with newTaskId:', newTaskId)
onNavigate?.(returnTo, {
wishlistId: returnWishlistId,
newTaskId: newTaskId,
})
} else {
console.log('[TaskForm] No returnTo, navigating to tasks')
// Стандартное поведение - возврат к списку задач
onNavigate?.('tasks')
}
} catch (err) {
setToastMessage({ text: err.message || 'Ошибка при сохранении задачи', type: 'error' })
console.error('Error saving task:', err)

View File

@@ -1,11 +1,13 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import './Wishlist.css'
const API_URL = '/api/wishlist'
const CACHE_KEY = 'wishlist_cache'
const CACHE_COMPLETED_KEY = 'wishlist_completed_cache'
function Wishlist({ onNavigate, refreshTrigger = 0 }) {
function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) {
const { authFetch } = useAuth()
const [items, setItems] = useState([])
const [completed, setCompleted] = useState([])
@@ -15,44 +17,84 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
const [completedExpanded, setCompletedExpanded] = useState(false)
const [completedLoading, setCompletedLoading] = useState(false)
const [selectedItem, setSelectedItem] = useState(null)
const [tasks, setTasks] = useState([])
const [projects, setProjects] = useState([])
const fetchingRef = useRef(false)
const fetchingCompletedRef = useRef(false)
const initialFetchDoneRef = useRef(false)
const prevIsActiveRef = useRef(isActive)
useEffect(() => {
fetchWishlist()
loadTasksAndProjects()
}, [])
const loadTasksAndProjects = async () => {
// Проверка наличия кэша
const hasCache = () => {
try {
// Загружаем задачи
const tasksResponse = await authFetch('/api/tasks')
if (tasksResponse.ok) {
const tasksData = await tasksResponse.json()
setTasks(Array.isArray(tasksData) ? tasksData : [])
}
// Загружаем проекты
const projectsResponse = await authFetch('/projects')
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json()
setProjects(Array.isArray(projectsData) ? projectsData : [])
}
return localStorage.getItem(CACHE_KEY) !== null
} catch (err) {
console.error('Error loading tasks and projects:', err)
return false
}
}
// Обновляем данные при изменении refreshTrigger
useEffect(() => {
if (refreshTrigger > 0) {
fetchWishlist()
}
}, [refreshTrigger])
const fetchWishlist = async () => {
// Загрузка основных данных из кэша
const loadFromCache = () => {
try {
setLoading(true)
const cached = localStorage.getItem(CACHE_KEY)
if (cached) {
const data = JSON.parse(cached)
setItems(data.items || [])
setCompletedCount(data.completedCount || 0)
return true
}
} catch (err) {
console.error('Error loading from cache:', err)
}
return false
}
// Загрузка завершённых из кэша
const loadCompletedFromCache = () => {
try {
const cached = localStorage.getItem(CACHE_COMPLETED_KEY)
if (cached) {
const data = JSON.parse(cached)
setCompleted(data || [])
return true
}
} catch (err) {
console.error('Error loading completed from cache:', err)
}
return false
}
// Сохранение основных данных в кэш
const saveToCache = (itemsData, count) => {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({
items: itemsData,
completedCount: count,
timestamp: Date.now()
}))
} catch (err) {
console.error('Error saving to cache:', err)
}
}
// Сохранение завершённых в кэш
const saveCompletedToCache = (completedData) => {
try {
localStorage.setItem(CACHE_COMPLETED_KEY, JSON.stringify(completedData))
} catch (err) {
console.error('Error saving completed to cache:', err)
}
}
// Загрузка основного списка
const fetchWishlist = async () => {
if (fetchingRef.current) return
fetchingRef.current = true
try {
const hasDataInState = items.length > 0 || completedCount > 0
const cacheExists = hasCache()
if (!hasDataInState && !cacheExists) {
setLoading(true)
}
const response = await authFetch(API_URL)
@@ -61,53 +103,125 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
}
const data = await response.json()
// Объединяем разблокированные и заблокированные в один список
const allItems = [...(data.unlocked || []), ...(data.locked || [])]
setItems(allItems)
const count = data.completed_count || 0
setItems(allItems)
setCompletedCount(count)
// Загружаем завершённые сразу, если они есть
if (count > 0) {
fetchCompleted()
} else {
setCompleted([])
}
saveToCache(allItems, count)
setError('')
} catch (err) {
setError(err.message)
setItems([])
setCompleted([])
setCompletedCount(0)
if (!hasCache()) {
setItems([])
setCompletedCount(0)
}
} finally {
setLoading(false)
fetchingRef.current = false
}
}
// Загрузка завершённых
const fetchCompleted = async () => {
if (fetchingCompletedRef.current) return
fetchingCompletedRef.current = true
try {
setCompletedLoading(true)
const response = await authFetch(`${API_URL}?include_completed=true`)
const response = await authFetch(`${API_URL}/completed`)
if (!response.ok) {
throw new Error('Ошибка при загрузке завершённых желаний')
}
const data = await response.json()
setCompleted(data.completed || [])
const completedData = Array.isArray(data) ? data : []
setCompleted(completedData)
saveCompletedToCache(completedData)
} catch (err) {
console.error('Error fetching completed items:', err)
setCompleted([])
} finally {
setCompletedLoading(false)
fetchingCompletedRef.current = false
}
}
// Первая инициализация
useEffect(() => {
if (!initialFetchDoneRef.current) {
initialFetchDoneRef.current = true
// Загружаем из кэша
const cacheLoaded = loadFromCache()
if (cacheLoaded) {
setLoading(false)
}
// Загружаем свежие данные
fetchWishlist()
// Если список завершённых раскрыт - загружаем их тоже
if (completedExpanded) {
loadCompletedFromCache()
fetchCompleted()
}
}
}, [])
// Обработка активации/деактивации таба
useEffect(() => {
const wasActive = prevIsActiveRef.current
prevIsActiveRef.current = isActive
// Пропускаем первую инициализацию (она обрабатывается отдельно)
if (!initialFetchDoneRef.current) return
// Когда таб становится видимым
if (isActive && !wasActive) {
// Показываем кэш, если есть данные
const hasDataInState = items.length > 0 || completedCount > 0
if (!hasDataInState) {
const cacheLoaded = loadFromCache()
if (cacheLoaded) {
setLoading(false)
} else {
setLoading(true)
}
}
// Всегда загружаем свежие данные основного списка
fetchWishlist()
// Если список завершённых раскрыт - загружаем их тоже
if (completedExpanded && completedCount > 0) {
fetchCompleted()
}
}
}, [isActive])
// Обновляем данные при изменении refreshTrigger
useEffect(() => {
if (refreshTrigger > 0) {
fetchWishlist()
if (completedExpanded && completedCount > 0) {
fetchCompleted()
}
}
}, [refreshTrigger])
const handleToggleCompleted = () => {
const newExpanded = !completedExpanded
setCompletedExpanded(newExpanded)
if (newExpanded && completed.length === 0 && completedCount > 0) {
// При раскрытии загружаем завершённые
if (newExpanded && completedCount > 0) {
// Показываем из кэша если есть
if (completed.length === 0) {
loadCompletedFromCache()
}
// Загружаем свежие данные
fetchCompleted()
}
}
@@ -145,7 +259,10 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
}
setSelectedItem(null)
await fetchWishlist(completedExpanded)
await fetchWishlist()
if (completedExpanded) {
await fetchCompleted()
}
} catch (err) {
setError(err.message)
setSelectedItem(null)
@@ -165,7 +282,10 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
}
setSelectedItem(null)
await fetchWishlist(completedExpanded)
await fetchWishlist()
if (completedExpanded) {
await fetchCompleted()
}
} catch (err) {
setError(err.message)
setSelectedItem(null)
@@ -176,97 +296,17 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
if (!selectedItem) return
try {
// Загружаем полные данные желания
const response = await authFetch(`${API_URL}/${selectedItem.id}`)
if (!response.ok) {
throw new Error('Ошибка при загрузке желания')
}
const itemData = await response.json()
// Преобразуем условия из формата Display в формат Request
const unlockConditions = (itemData.unlock_conditions || []).map((cond) => {
const condition = {
type: cond.type,
display_order: cond.display_order,
}
if (cond.type === 'task_completion' && cond.task_name) {
// Находим task_id по имени задачи
const task = tasks.find(t => t.name === cond.task_name)
if (task) {
condition.task_id = task.id
}
} else if (cond.type === 'project_points' && cond.project_name) {
// Находим project_id по имени проекта
const project = projects.find(p => p.project_name === cond.project_name)
if (project) {
condition.project_id = project.project_id
}
if (cond.required_points !== undefined && cond.required_points !== null) {
condition.required_points = cond.required_points
}
if (cond.start_date) {
condition.start_date = cond.start_date
}
}
return condition
})
// Создаем копию желания
const copyData = {
name: `${itemData.name} (копия)`,
price: itemData.price || null,
link: itemData.link || null,
unlock_conditions: unlockConditions,
}
const createResponse = await authFetch(API_URL, {
const response = await authFetch(`${API_URL}/${selectedItem.id}/copy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(copyData),
})
if (!createResponse.ok) {
throw new Error('Ошибка при создании копии')
if (!response.ok) {
throw new Error('Ошибка при копировании')
}
const newItem = await createResponse.json()
// Копируем изображение, если оно есть
if (itemData.image_url) {
try {
// Загружаем изображение по URL (используем authFetch для авторизованных запросов)
const imageResponse = await authFetch(itemData.image_url)
if (imageResponse.ok) {
const blob = await imageResponse.blob()
// Проверяем, что это изображение и размер не превышает 5MB
if (blob.type.startsWith('image/') && blob.size <= 5 * 1024 * 1024) {
// Загружаем изображение для нового желания
const formData = new FormData()
formData.append('image', blob, 'image.jpg')
const uploadResponse = await authFetch(`${API_URL}/${newItem.id}/image`, {
method: 'POST',
body: formData,
})
if (!uploadResponse.ok) {
console.error('Ошибка при копировании изображения')
}
}
}
} catch (imgErr) {
console.error('Ошибка при копировании изображения:', imgErr)
// Не прерываем процесс, просто логируем ошибку
}
}
const newItem = await response.json()
setSelectedItem(null)
// Открываем экран редактирования нового желания
onNavigate?.('wishlist-form', { wishlistId: newItem.id })
} catch (err) {
setError(err.message)
@@ -283,7 +323,6 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
}).format(price)
}
// Находит первое невыполненное условие
const findFirstUnmetCondition = (item) => {
if (!item.unlock_conditions || item.unlock_conditions.length === 0) {
return null
@@ -293,10 +332,8 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
let isMet = false
if (condition.type === 'task_completion') {
// Условие выполнено, если task_completed === true
isMet = condition.task_completed === true
} else if (condition.type === 'project_points') {
// Условие выполнено, если current_points >= required_points
const currentPoints = condition.current_points || 0
const requiredPoints = condition.required_points || 0
isMet = currentPoints >= requiredPoints
@@ -380,12 +417,10 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
<div className="card-name">{item.name}</div>
{(() => {
// Показываем первое невыполненное условие, если есть
const unmetCondition = findFirstUnmetCondition(item)
if (unmetCondition && !item.completed) {
return renderUnlockCondition(item)
}
// Если все условия выполнены или условий нет - показываем цену
if (item.price) {
return <div className="card-price">{formatPrice(item.price)}</div>
}

View File

@@ -458,3 +458,131 @@
transform: scale(0.95);
}
/* Task Autocomplete Styles */
.task-autocomplete {
position: relative;
}
.task-autocomplete-row {
display: flex;
gap: 8px;
align-items: center;
}
.task-autocomplete-input-wrapper {
flex: 1;
position: relative;
}
.task-autocomplete-input {
width: 100%;
padding: 12px 36px 12px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s, box-shadow 0.2s;
background: white;
}
.task-autocomplete-input:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.task-autocomplete-input::placeholder {
color: #9ca3af;
}
.task-autocomplete-clear {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 4px;
font-size: 12px;
line-height: 1;
border-radius: 4px;
transition: all 0.15s;
}
.task-autocomplete-clear:hover {
color: #6b7280;
background: #f3f4f6;
}
/* Кнопка создания */
.create-task-button {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
background: #4f46e5;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
}
.create-task-button:hover {
background: #4338ca;
}
/* Dropdown список */
.task-autocomplete-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 52px; /* Учитываем ширину кнопки + gap */
max-height: 240px;
overflow-y: auto;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 50;
}
.task-autocomplete-empty {
padding: 16px;
text-align: center;
color: #9ca3af;
font-size: 14px;
}
.task-autocomplete-item {
padding: 12px 14px;
cursor: pointer;
font-size: 14px;
color: #374151;
border-bottom: 1px solid #f3f4f6;
transition: background 0.1s;
}
.task-autocomplete-item:last-child {
border-bottom: none;
}
.task-autocomplete-item:hover,
.task-autocomplete-item.highlighted {
background: #f3f4f6;
}
.task-autocomplete-item.selected {
background: #eef2ff;
color: #4f46e5;
font-weight: 500;
}
.task-autocomplete-item.selected.highlighted {
background: #e0e7ff;
}

View File

@@ -7,8 +7,9 @@ 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 }) {
function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId }) {
const { authFetch } = useAuth()
const [name, setName] = useState('')
const [price, setPrice] = useState('')
@@ -29,6 +30,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex }) {
const [toastMessage, setToastMessage] = useState(null)
const [loadingWishlist, setLoadingWishlist] = useState(false)
const [fetchingMetadata, setFetchingMetadata] = useState(false)
const [restoredFromSession, setRestoredFromSession] = useState(false) // Флаг восстановления из sessionStorage
const fileInputRef = useRef(null)
// Загрузка задач и проектов
@@ -57,13 +59,19 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex }) {
// Загрузка желания при редактировании или сброс формы при создании
useEffect(() => {
// Пропускаем загрузку, если состояние было восстановлено из sessionStorage
if (restoredFromSession) {
console.log('[WishlistForm] Skipping loadWishlist - restored from session')
return
}
if (wishlistId !== undefined && wishlistId !== null && tasks.length > 0 && projects.length > 0) {
loadWishlist()
} else if (wishlistId === undefined || wishlistId === null) {
// Сбрасываем форму при создании новой задачи
resetForm()
}
}, [wishlistId, tasks, projects])
}, [wishlistId, tasks, projects, restoredFromSession])
// Сброс формы при размонтировании компонента или при изменении wishlistId на undefined
useEffect(() => {
@@ -84,6 +92,89 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex }) {
}
}, [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 {
@@ -356,6 +447,30 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex }) {
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('')
@@ -640,6 +755,8 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex }) {
onSubmit={handleConditionSubmit}
onCancel={handleConditionCancel}
editingCondition={editingConditionIndex !== null ? unlockConditions[editingConditionIndex] : null}
onCreateTask={handleCreateTaskFromCondition}
preselectedTaskId={newTaskId}
/>
)}
@@ -732,22 +849,211 @@ function DateSelector({ value, onChange, placeholder = "За всё время"
)
}
// Компонент автодополнения для выбора задачи
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 }) {
function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition, onCreateTask, preselectedTaskId }) {
const [type, setType] = useState(editingCondition?.type || 'project_points')
const [taskId, setTaskId] = useState(editingCondition?.task_id?.toString() || '')
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) {
if (type === 'task_completion' && (!taskId || taskId === null)) {
return
}
if (type === 'project_points' && (!projectId || !requiredPoints)) {
@@ -756,7 +1062,7 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition }
const condition = {
type,
task_id: type === 'task_completion' ? parseInt(taskId) : null,
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,
@@ -764,7 +1070,7 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition }
onSubmit(condition)
// Сброс формы
setType('project_points')
setTaskId('')
setTaskId(null)
setProjectId('')
setRequiredPoints('')
setStartDate('')
@@ -790,17 +1096,13 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition }
{type === 'task_completion' && (
<div className="form-group">
<label>Задача</label>
<select
<TaskAutocomplete
tasks={tasks}
value={taskId}
onChange={(e) => setTaskId(e.target.value)}
className="form-input"
required
>
<option value="">Выберите задачу</option>
{tasks.map(task => (
<option key={task.id} value={task.id}>{task.name}</option>
))}
</select>
onChange={(id) => setTaskId(id)}
onCreateTask={onCreateTask}
preselectedTaskId={preselectedTaskId}
/>
</div>
)}