From ce7e0e584a5671d42cc14a9608f0820b1e542d0d Mon Sep 17 00:00:00 2001 From: poignatov Date: Tue, 13 Jan 2026 20:55:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20wishlist:=20=D1=80=D0=B0=D0=B7=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=B7=D0=B0=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D1=8B=20=D0=B8=20=D0=BA=D0=BE=D0=BF=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- play-life-backend/main.go | 310 +++++++++++++++- play-life-web/package.json | 2 +- play-life-web/src/App.jsx | 12 +- play-life-web/src/components/TaskForm.jsx | 23 +- play-life-web/src/components/Wishlist.jsx | 315 ++++++++-------- play-life-web/src/components/WishlistForm.css | 128 +++++++ play-life-web/src/components/WishlistForm.jsx | 336 +++++++++++++++++- 8 files changed, 943 insertions(+), 185 deletions(-) diff --git a/VERSION b/VERSION index afad818..92536a9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.11.0 +3.12.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index bbff993..b8773b8 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "database/sql" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "html" @@ -4055,6 +4056,7 @@ func main() { // Wishlist protected.HandleFunc("/api/wishlist", app.getWishlistHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist", app.createWishlistHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/wishlist/completed", app.getWishlistCompletedHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/metadata", app.extractLinkMetadataHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist/{id}", app.getWishlistItemHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/{id}", app.updateWishlistHandler).Methods("PUT", "OPTIONS") @@ -4062,6 +4064,7 @@ func main() { protected.HandleFunc("/api/wishlist/{id}/image", app.uploadWishlistImageHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist/{id}/complete", app.completeWishlistHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist/{id}/uncomplete", app.uncompleteWishlistHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/wishlist/{id}/copy", app.copyWishlistHandler).Methods("POST", "OPTIONS") // Admin operations protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS") @@ -9527,7 +9530,7 @@ func (a *App) saveWishlistConditions( return nil } -// getWishlistHandler возвращает список желаний +// getWishlistHandler возвращает список незавершённых желаний и счётчик завершённых func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) @@ -9542,16 +9545,15 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) { return } - includeCompleted := r.URL.Query().Get("include_completed") == "true" - - items, err := a.getWishlistItemsWithConditions(userID, includeCompleted) + // Загружаем только незавершённые + items, err := a.getWishlistItemsWithConditions(userID, false) if err != nil { log.Printf("Error getting wishlist items: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist items: %v", err), http.StatusInternalServerError) return } - // Получаем количество завершённых отдельным запросом (т.к. основной запрос может их не включать) + // Получаем количество завершённых var completedCount int err = a.DB.QueryRow(` SELECT COUNT(*) FROM wishlist_items @@ -9565,14 +9567,9 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) { // Группируем и сортируем unlocked := make([]WishlistItem, 0) locked := make([]WishlistItem, 0) - completed := make([]WishlistItem, 0) for _, item := range items { - if item.Completed { - if includeCompleted { - completed = append(completed, item) - } - } else if item.Unlocked { + if item.Unlocked { unlocked = append(unlocked, item) } else { locked = append(locked, item) @@ -9604,6 +9601,48 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) { return priceI > priceJ }) + response := WishlistResponse{ + Unlocked: unlocked, + Locked: locked, + CompletedCount: completedCount, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// getWishlistCompletedHandler возвращает список завершённых желаний +func (a *App) getWishlistCompletedHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Загружаем все желания включая завершённые + items, err := a.getWishlistItemsWithConditions(userID, true) + if err != nil { + log.Printf("Error getting completed wishlist items: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error getting completed wishlist items: %v", err), http.StatusInternalServerError) + return + } + + // Фильтруем только завершённые + completed := make([]WishlistItem, 0) + for _, item := range items { + if item.Completed { + completed = append(completed, item) + } + } + + // Сортируем по цене (дорогие → дешёвые) sort.Slice(completed, func(i, j int) bool { priceI := 0.0 priceJ := 0.0 @@ -9616,15 +9655,8 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) { return priceI > priceJ }) - response := WishlistResponse{ - Unlocked: unlocked, - Locked: locked, - Completed: completed, - CompletedCount: completedCount, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + json.NewEncoder(w).Encode(completed) } // createWishlistHandler создаёт новое желание @@ -10183,6 +10215,246 @@ func (a *App) uncompleteWishlistHandler(w http.ResponseWriter, r *http.Request) }) } +// copyWishlistHandler копирует желание +func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + itemID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest) + return + } + + // Получаем оригинальное желание + var name string + var price sql.NullFloat64 + var link sql.NullString + var imagePath sql.NullString + var ownerID int + err = a.DB.QueryRow(` + SELECT user_id, name, price, link, image_path + FROM wishlist_items + WHERE id = $1 AND deleted = FALSE + `, itemID).Scan(&ownerID, &name, &price, &link, &imagePath) + + if err == sql.ErrNoRows || ownerID != userID { + sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting wishlist item: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist item: %v", err), http.StatusInternalServerError) + return + } + + // Получаем условия оригинального желания + rows, err := a.DB.Query(` + SELECT + wc.display_order, + wc.task_condition_id, + wc.score_condition_id, + tc.task_id, + sc.project_id, + sc.required_points, + sc.start_date + FROM wishlist_conditions wc + LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id + LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id + WHERE wc.wishlist_item_id = $1 + ORDER BY wc.display_order + `, itemID) + if err != nil { + log.Printf("Error getting wishlist conditions: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist conditions: %v", err), http.StatusInternalServerError) + return + } + defer rows.Close() + + var conditions []UnlockConditionRequest + for rows.Next() { + var displayOrder int + var taskConditionID, scoreConditionID sql.NullInt64 + var taskID, projectID sql.NullInt64 + var requiredPoints sql.NullFloat64 + var startDate sql.NullString + + err := rows.Scan(&displayOrder, &taskConditionID, &scoreConditionID, &taskID, &projectID, &requiredPoints, &startDate) + if err != nil { + log.Printf("Error scanning condition row: %v", err) + continue + } + + cond := UnlockConditionRequest{ + DisplayOrder: &displayOrder, + } + + if taskConditionID.Valid && taskID.Valid { + cond.Type = "task_completion" + tid := int(taskID.Int64) + cond.TaskID = &tid + } else if scoreConditionID.Valid && projectID.Valid { + cond.Type = "project_points" + pid := int(projectID.Int64) + cond.ProjectID = &pid + if requiredPoints.Valid { + cond.RequiredPoints = &requiredPoints.Float64 + } + if startDate.Valid { + cond.StartDate = &startDate.String + } + } + + conditions = append(conditions, cond) + } + + // Создаём копию в транзакции + tx, err := a.DB.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Создаём копию желания + var newWishlistID int + var priceVal, linkVal interface{} + if price.Valid { + priceVal = price.Float64 + } + if link.Valid { + linkVal = link.String + } + + err = tx.QueryRow(` + INSERT INTO wishlist_items (user_id, name, price, link, completed, deleted) + VALUES ($1, $2, $3, $4, FALSE, FALSE) + RETURNING id + `, userID, name+" (копия)", priceVal, linkVal).Scan(&newWishlistID) + if err != nil { + log.Printf("Error creating wishlist copy: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating wishlist copy: %v", err), http.StatusInternalServerError) + return + } + + // Сохраняем условия + if len(conditions) > 0 { + err = a.saveWishlistConditions(tx, newWishlistID, conditions) + if err != nil { + log.Printf("Error saving wishlist conditions: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error saving wishlist conditions: %v", err), http.StatusInternalServerError) + return + } + } + + // Копируем изображение, если есть + if imagePath.Valid && imagePath.String != "" { + // Получаем путь к оригинальному файлу + uploadsDir := getEnv("UPLOADS_DIR", "/app/uploads") + + // Очищаем путь от /uploads/ в начале и query параметров + cleanPath := imagePath.String + cleanPath = strings.TrimPrefix(cleanPath, "/uploads/") + if idx := strings.Index(cleanPath, "?"); idx != -1 { + cleanPath = cleanPath[:idx] + } + + originalPath := filepath.Join(uploadsDir, cleanPath) + + log.Printf("Copying image: imagePath=%s, cleanPath=%s, originalPath=%s", imagePath.String, cleanPath, originalPath) + + // Проверяем, существует ли файл + if _, statErr := os.Stat(originalPath); statErr == nil { + // Создаём директорию для нового желания + newImageDir := filepath.Join(uploadsDir, "wishlist", strconv.Itoa(userID)) + if mkdirErr := os.MkdirAll(newImageDir, 0755); mkdirErr != nil { + log.Printf("Error creating image dir: %v", mkdirErr) + } + + // Генерируем уникальное имя файла + ext := filepath.Ext(cleanPath) + randomBytes := make([]byte, 8) + rand.Read(randomBytes) + newFileName := fmt.Sprintf("%d_%s%s", newWishlistID, hex.EncodeToString(randomBytes), ext) + newImagePath := filepath.Join(newImageDir, newFileName) + + log.Printf("New image path: %s", newImagePath) + + // Копируем файл + srcFile, openErr := os.Open(originalPath) + if openErr != nil { + log.Printf("Error opening source file: %v", openErr) + } else { + defer srcFile.Close() + dstFile, createErr := os.Create(newImagePath) + if createErr != nil { + log.Printf("Error creating dest file: %v", createErr) + } else { + defer dstFile.Close() + _, copyErr := io.Copy(dstFile, srcFile) + if copyErr != nil { + log.Printf("Error copying file: %v", copyErr) + } else { + // Обновляем путь к изображению в БД (с /uploads/ в начале для совместимости) + relativePath := "/uploads/" + filepath.Join("wishlist", strconv.Itoa(userID), newFileName) + log.Printf("Updating image_path in DB to: %s", relativePath) + _, updateErr := tx.Exec(`UPDATE wishlist_items SET image_path = $1 WHERE id = $2`, relativePath, newWishlistID) + if updateErr != nil { + log.Printf("Error updating image_path in DB: %v", updateErr) + } + } + } + } + } else { + log.Printf("Original image file not found: %s, error: %v", originalPath, statErr) + } + } else { + log.Printf("No image to copy: imagePath.Valid=%v, imagePath.String=%s", imagePath.Valid, imagePath.String) + } + + if err := tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) + return + } + + // Получаем созданное желание с условиями + items, err := a.getWishlistItemsWithConditions(userID, false) + if err != nil { + log.Printf("Error getting created wishlist item: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error getting created wishlist item: %v", err), http.StatusInternalServerError) + return + } + + var createdItem *WishlistItem + for i := range items { + if items[i].ID == newWishlistID { + createdItem = &items[i] + break + } + } + + if createdItem == nil { + sendErrorWithCORS(w, "Created item not found", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(createdItem) +} + // LinkMetadataResponse структура ответа с метаданными ссылки type LinkMetadataResponse struct { Title string `json:"title,omitempty"` diff --git a/play-life-web/package.json b/play-life-web/package.json index abe0cb3..a4d46e2 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "3.11.0", + "version": "3.12.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index 8140369..20f167c 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -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} /> )} @@ -857,6 +859,7 @@ function AppContent() { )} @@ -864,10 +867,11 @@ function AppContent() { {loadedTabs['wishlist-form'] && (
)} diff --git a/play-life-web/src/components/TaskForm.jsx b/play-life-web/src/components/TaskForm.jsx index a16d025..9b015b9 100644 --- a/play-life-web/src/components/TaskForm.jsx +++ b/play-life-web/src/components/TaskForm.jsx @@ -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) diff --git a/play-life-web/src/components/Wishlist.jsx b/play-life-web/src/components/Wishlist.jsx index b6ceced..791cc28 100644 --- a/play-life-web/src/components/Wishlist.jsx +++ b/play-life-web/src/components/Wishlist.jsx @@ -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 }) {
{item.name}
{(() => { - // Показываем первое невыполненное условие, если есть const unmetCondition = findFirstUnmetCondition(item) if (unmetCondition && !item.completed) { return renderUnlockCondition(item) } - // Если все условия выполнены или условий нет - показываем цену if (item.price) { return
{formatPrice(item.price)}
} diff --git a/play-life-web/src/components/WishlistForm.css b/play-life-web/src/components/WishlistForm.css index dc775c2..cbbe0eb 100644 --- a/play-life-web/src/components/WishlistForm.css +++ b/play-life-web/src/components/WishlistForm.css @@ -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; +} + diff --git a/play-life-web/src/components/WishlistForm.jsx b/play-life-web/src/components/WishlistForm.jsx index 5cd11f5..6cda7c2 100644 --- a/play-life-web/src/components/WishlistForm.jsx +++ b/play-life-web/src/components/WishlistForm.jsx @@ -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 ( +
+
+
+ + {inputValue && ( + + )} +
+ +
+ + {isOpen && ( +
+ {filteredTasks.length === 0 ? ( +
+ {inputValue ? 'Задачи не найдены' : 'Нет доступных задач'} +
+ ) : ( + filteredTasks.map((task, index) => ( +
handleSelectTask(task)} + onMouseEnter={() => setHighlightedIndex(index)} + > + {task.name} +
+ )) + )} +
+ )} +
+ ) +} + // Компонент формы цели -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' && (
- + onChange={(id) => setTaskId(id)} + onCreateTask={onCreateTask} + preselectedTaskId={preselectedTaskId} + />
)}