Оптимизация wishlist: раздельные запросы и копирование
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m14s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m14s
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
@@ -4055,6 +4056,7 @@ func main() {
|
|||||||
// Wishlist
|
// Wishlist
|
||||||
protected.HandleFunc("/api/wishlist", app.getWishlistHandler).Methods("GET", "OPTIONS")
|
protected.HandleFunc("/api/wishlist", app.getWishlistHandler).Methods("GET", "OPTIONS")
|
||||||
protected.HandleFunc("/api/wishlist", app.createWishlistHandler).Methods("POST", "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/metadata", app.extractLinkMetadataHandler).Methods("POST", "OPTIONS")
|
||||||
protected.HandleFunc("/api/wishlist/{id}", app.getWishlistItemHandler).Methods("GET", "OPTIONS")
|
protected.HandleFunc("/api/wishlist/{id}", app.getWishlistItemHandler).Methods("GET", "OPTIONS")
|
||||||
protected.HandleFunc("/api/wishlist/{id}", app.updateWishlistHandler).Methods("PUT", "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}/image", app.uploadWishlistImageHandler).Methods("POST", "OPTIONS")
|
||||||
protected.HandleFunc("/api/wishlist/{id}/complete", app.completeWishlistHandler).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}/uncomplete", app.uncompleteWishlistHandler).Methods("POST", "OPTIONS")
|
||||||
|
protected.HandleFunc("/api/wishlist/{id}/copy", app.copyWishlistHandler).Methods("POST", "OPTIONS")
|
||||||
|
|
||||||
// Admin operations
|
// Admin operations
|
||||||
protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
|
protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
|
||||||
@@ -9527,7 +9530,7 @@ func (a *App) saveWishlistConditions(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getWishlistHandler возвращает список желаний
|
// getWishlistHandler возвращает список незавершённых желаний и счётчик завершённых
|
||||||
func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == "OPTIONS" {
|
if r.Method == "OPTIONS" {
|
||||||
setCORSHeaders(w)
|
setCORSHeaders(w)
|
||||||
@@ -9542,16 +9545,15 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
includeCompleted := r.URL.Query().Get("include_completed") == "true"
|
// Загружаем только незавершённые
|
||||||
|
items, err := a.getWishlistItemsWithConditions(userID, false)
|
||||||
items, err := a.getWishlistItemsWithConditions(userID, includeCompleted)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error getting wishlist items: %v", err)
|
log.Printf("Error getting wishlist items: %v", err)
|
||||||
sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist items: %v", err), http.StatusInternalServerError)
|
sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist items: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем количество завершённых отдельным запросом (т.к. основной запрос может их не включать)
|
// Получаем количество завершённых
|
||||||
var completedCount int
|
var completedCount int
|
||||||
err = a.DB.QueryRow(`
|
err = a.DB.QueryRow(`
|
||||||
SELECT COUNT(*) FROM wishlist_items
|
SELECT COUNT(*) FROM wishlist_items
|
||||||
@@ -9565,14 +9567,9 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Группируем и сортируем
|
// Группируем и сортируем
|
||||||
unlocked := make([]WishlistItem, 0)
|
unlocked := make([]WishlistItem, 0)
|
||||||
locked := make([]WishlistItem, 0)
|
locked := make([]WishlistItem, 0)
|
||||||
completed := make([]WishlistItem, 0)
|
|
||||||
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if item.Completed {
|
if item.Unlocked {
|
||||||
if includeCompleted {
|
|
||||||
completed = append(completed, item)
|
|
||||||
}
|
|
||||||
} else if item.Unlocked {
|
|
||||||
unlocked = append(unlocked, item)
|
unlocked = append(unlocked, item)
|
||||||
} else {
|
} else {
|
||||||
locked = append(locked, item)
|
locked = append(locked, item)
|
||||||
@@ -9604,6 +9601,48 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return priceI > priceJ
|
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 {
|
sort.Slice(completed, func(i, j int) bool {
|
||||||
priceI := 0.0
|
priceI := 0.0
|
||||||
priceJ := 0.0
|
priceJ := 0.0
|
||||||
@@ -9616,15 +9655,8 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return priceI > priceJ
|
return priceI > priceJ
|
||||||
})
|
})
|
||||||
|
|
||||||
response := WishlistResponse{
|
|
||||||
Unlocked: unlocked,
|
|
||||||
Locked: locked,
|
|
||||||
Completed: completed,
|
|
||||||
CompletedCount: completedCount,
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(completed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// createWishlistHandler создаёт новое желание
|
// 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 структура ответа с метаданными ссылки
|
// LinkMetadataResponse структура ответа с метаданными ссылки
|
||||||
type LinkMetadataResponse struct {
|
type LinkMetadataResponse struct {
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "3.11.0",
|
"version": "3.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -615,9 +615,9 @@ function AppContent() {
|
|||||||
|
|
||||||
{
|
{
|
||||||
// Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров
|
// Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров
|
||||||
// task-form может иметь taskId (редактирование) или wishlistId (создание из желания)
|
// task-form может иметь taskId (редактирование), wishlistId (создание из желания), или returnTo (возврат после создания)
|
||||||
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined
|
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === undefined
|
||||||
const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined
|
const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined && params.newTaskId === undefined
|
||||||
if (isTaskFormWithNoParams || isWishlistFormWithNoParams) {
|
if (isTaskFormWithNoParams || isWishlistFormWithNoParams) {
|
||||||
setTabParams({})
|
setTabParams({})
|
||||||
if (isNewTabMain) {
|
if (isNewTabMain) {
|
||||||
@@ -848,6 +848,8 @@ function AppContent() {
|
|||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
taskId={tabParams.taskId}
|
taskId={tabParams.taskId}
|
||||||
wishlistId={tabParams.wishlistId}
|
wishlistId={tabParams.wishlistId}
|
||||||
|
returnTo={tabParams.returnTo}
|
||||||
|
returnWishlistId={tabParams.returnWishlistId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -857,6 +859,7 @@ function AppContent() {
|
|||||||
<Wishlist
|
<Wishlist
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
refreshTrigger={wishlistRefreshTrigger}
|
refreshTrigger={wishlistRefreshTrigger}
|
||||||
|
isActive={activeTab === 'wishlist'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -864,10 +867,11 @@ function AppContent() {
|
|||||||
{loadedTabs['wishlist-form'] && (
|
{loadedTabs['wishlist-form'] && (
|
||||||
<div className={activeTab === 'wishlist-form' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'wishlist-form' ? 'block' : 'hidden'}>
|
||||||
<WishlistForm
|
<WishlistForm
|
||||||
key={`${tabParams.wishlistId || 'new'}-${tabParams.editConditionIndex ?? ''}`}
|
key={`${tabParams.wishlistId || 'new'}-${tabParams.editConditionIndex ?? ''}-${tabParams.newTaskId ?? ''}`}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
wishlistId={tabParams.wishlistId}
|
wishlistId={tabParams.wishlistId}
|
||||||
editConditionIndex={tabParams.editConditionIndex}
|
editConditionIndex={tabParams.editConditionIndex}
|
||||||
|
newTaskId={tabParams.newTaskId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import './TaskForm.css'
|
|||||||
const API_URL = '/api/tasks'
|
const API_URL = '/api/tasks'
|
||||||
const PROJECTS_API_URL = '/projects'
|
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 { authFetch } = useAuth()
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [progressionBase, setProgressionBase] = useState('')
|
const [progressionBase, setProgressionBase] = useState('')
|
||||||
@@ -678,11 +678,28 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
|
|||||||
throw new Error(errorMessage)
|
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()
|
resetForm()
|
||||||
|
|
||||||
// Возвращаемся к списку задач
|
// Если был returnTo, возвращаемся на форму желания с ID новой задачи
|
||||||
onNavigate?.('tasks')
|
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) {
|
} catch (err) {
|
||||||
setToastMessage({ text: err.message || 'Ошибка при сохранении задачи', type: 'error' })
|
setToastMessage({ text: err.message || 'Ошибка при сохранении задачи', type: 'error' })
|
||||||
console.error('Error saving task:', err)
|
console.error('Error saving task:', err)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { useAuth } from './auth/AuthContext'
|
import { useAuth } from './auth/AuthContext'
|
||||||
import LoadingError from './LoadingError'
|
import LoadingError from './LoadingError'
|
||||||
import './Wishlist.css'
|
import './Wishlist.css'
|
||||||
|
|
||||||
const API_URL = '/api/wishlist'
|
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 { authFetch } = useAuth()
|
||||||
const [items, setItems] = useState([])
|
const [items, setItems] = useState([])
|
||||||
const [completed, setCompleted] = useState([])
|
const [completed, setCompleted] = useState([])
|
||||||
@@ -15,44 +17,84 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
const [completedExpanded, setCompletedExpanded] = useState(false)
|
const [completedExpanded, setCompletedExpanded] = useState(false)
|
||||||
const [completedLoading, setCompletedLoading] = useState(false)
|
const [completedLoading, setCompletedLoading] = useState(false)
|
||||||
const [selectedItem, setSelectedItem] = useState(null)
|
const [selectedItem, setSelectedItem] = useState(null)
|
||||||
const [tasks, setTasks] = useState([])
|
const fetchingRef = useRef(false)
|
||||||
const [projects, setProjects] = useState([])
|
const fetchingCompletedRef = useRef(false)
|
||||||
|
const initialFetchDoneRef = useRef(false)
|
||||||
|
const prevIsActiveRef = useRef(isActive)
|
||||||
|
|
||||||
useEffect(() => {
|
// Проверка наличия кэша
|
||||||
fetchWishlist()
|
const hasCache = () => {
|
||||||
loadTasksAndProjects()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadTasksAndProjects = async () => {
|
|
||||||
try {
|
try {
|
||||||
// Загружаем задачи
|
return localStorage.getItem(CACHE_KEY) !== null
|
||||||
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 : [])
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading tasks and projects:', err)
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем данные при изменении refreshTrigger
|
// Загрузка основных данных из кэша
|
||||||
useEffect(() => {
|
const loadFromCache = () => {
|
||||||
if (refreshTrigger > 0) {
|
|
||||||
fetchWishlist()
|
|
||||||
}
|
|
||||||
}, [refreshTrigger])
|
|
||||||
|
|
||||||
const fetchWishlist = async () => {
|
|
||||||
try {
|
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)
|
const response = await authFetch(API_URL)
|
||||||
|
|
||||||
@@ -61,53 +103,125 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
// Объединяем разблокированные и заблокированные в один список
|
|
||||||
const allItems = [...(data.unlocked || []), ...(data.locked || [])]
|
const allItems = [...(data.unlocked || []), ...(data.locked || [])]
|
||||||
setItems(allItems)
|
|
||||||
const count = data.completed_count || 0
|
const count = data.completed_count || 0
|
||||||
|
|
||||||
|
setItems(allItems)
|
||||||
setCompletedCount(count)
|
setCompletedCount(count)
|
||||||
|
saveToCache(allItems, count)
|
||||||
// Загружаем завершённые сразу, если они есть
|
|
||||||
if (count > 0) {
|
|
||||||
fetchCompleted()
|
|
||||||
} else {
|
|
||||||
setCompleted([])
|
|
||||||
}
|
|
||||||
|
|
||||||
setError('')
|
setError('')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
setItems([])
|
if (!hasCache()) {
|
||||||
setCompleted([])
|
setItems([])
|
||||||
setCompletedCount(0)
|
setCompletedCount(0)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
fetchingRef.current = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Загрузка завершённых
|
||||||
const fetchCompleted = async () => {
|
const fetchCompleted = async () => {
|
||||||
|
if (fetchingCompletedRef.current) return
|
||||||
|
fetchingCompletedRef.current = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setCompletedLoading(true)
|
setCompletedLoading(true)
|
||||||
const response = await authFetch(`${API_URL}?include_completed=true`)
|
const response = await authFetch(`${API_URL}/completed`)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке завершённых желаний')
|
throw new Error('Ошибка при загрузке завершённых желаний')
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setCompleted(data.completed || [])
|
const completedData = Array.isArray(data) ? data : []
|
||||||
|
setCompleted(completedData)
|
||||||
|
saveCompletedToCache(completedData)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching completed items:', err)
|
console.error('Error fetching completed items:', err)
|
||||||
setCompleted([])
|
setCompleted([])
|
||||||
} finally {
|
} finally {
|
||||||
setCompletedLoading(false)
|
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 handleToggleCompleted = () => {
|
||||||
const newExpanded = !completedExpanded
|
const newExpanded = !completedExpanded
|
||||||
setCompletedExpanded(newExpanded)
|
setCompletedExpanded(newExpanded)
|
||||||
if (newExpanded && completed.length === 0 && completedCount > 0) {
|
|
||||||
|
// При раскрытии загружаем завершённые
|
||||||
|
if (newExpanded && completedCount > 0) {
|
||||||
|
// Показываем из кэша если есть
|
||||||
|
if (completed.length === 0) {
|
||||||
|
loadCompletedFromCache()
|
||||||
|
}
|
||||||
|
// Загружаем свежие данные
|
||||||
fetchCompleted()
|
fetchCompleted()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,7 +259,10 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSelectedItem(null)
|
setSelectedItem(null)
|
||||||
await fetchWishlist(completedExpanded)
|
await fetchWishlist()
|
||||||
|
if (completedExpanded) {
|
||||||
|
await fetchCompleted()
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
setSelectedItem(null)
|
setSelectedItem(null)
|
||||||
@@ -165,7 +282,10 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSelectedItem(null)
|
setSelectedItem(null)
|
||||||
await fetchWishlist(completedExpanded)
|
await fetchWishlist()
|
||||||
|
if (completedExpanded) {
|
||||||
|
await fetchCompleted()
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
setSelectedItem(null)
|
setSelectedItem(null)
|
||||||
@@ -176,97 +296,17 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
if (!selectedItem) return
|
if (!selectedItem) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Загружаем полные данные желания
|
const response = await authFetch(`${API_URL}/${selectedItem.id}/copy`, {
|
||||||
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, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(copyData),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!createResponse.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при создании копии')
|
throw new Error('Ошибка при копировании')
|
||||||
}
|
}
|
||||||
|
|
||||||
const newItem = await createResponse.json()
|
const newItem = await response.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)
|
|
||||||
// Не прерываем процесс, просто логируем ошибку
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedItem(null)
|
setSelectedItem(null)
|
||||||
// Открываем экран редактирования нового желания
|
|
||||||
onNavigate?.('wishlist-form', { wishlistId: newItem.id })
|
onNavigate?.('wishlist-form', { wishlistId: newItem.id })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
@@ -283,7 +323,6 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
}).format(price)
|
}).format(price)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Находит первое невыполненное условие
|
|
||||||
const findFirstUnmetCondition = (item) => {
|
const findFirstUnmetCondition = (item) => {
|
||||||
if (!item.unlock_conditions || item.unlock_conditions.length === 0) {
|
if (!item.unlock_conditions || item.unlock_conditions.length === 0) {
|
||||||
return null
|
return null
|
||||||
@@ -293,10 +332,8 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
let isMet = false
|
let isMet = false
|
||||||
|
|
||||||
if (condition.type === 'task_completion') {
|
if (condition.type === 'task_completion') {
|
||||||
// Условие выполнено, если task_completed === true
|
|
||||||
isMet = condition.task_completed === true
|
isMet = condition.task_completed === true
|
||||||
} else if (condition.type === 'project_points') {
|
} else if (condition.type === 'project_points') {
|
||||||
// Условие выполнено, если current_points >= required_points
|
|
||||||
const currentPoints = condition.current_points || 0
|
const currentPoints = condition.current_points || 0
|
||||||
const requiredPoints = condition.required_points || 0
|
const requiredPoints = condition.required_points || 0
|
||||||
isMet = currentPoints >= requiredPoints
|
isMet = currentPoints >= requiredPoints
|
||||||
@@ -380,12 +417,10 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
<div className="card-name">{item.name}</div>
|
<div className="card-name">{item.name}</div>
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
// Показываем первое невыполненное условие, если есть
|
|
||||||
const unmetCondition = findFirstUnmetCondition(item)
|
const unmetCondition = findFirstUnmetCondition(item)
|
||||||
if (unmetCondition && !item.completed) {
|
if (unmetCondition && !item.completed) {
|
||||||
return renderUnlockCondition(item)
|
return renderUnlockCondition(item)
|
||||||
}
|
}
|
||||||
// Если все условия выполнены или условий нет - показываем цену
|
|
||||||
if (item.price) {
|
if (item.price) {
|
||||||
return <div className="card-price">{formatPrice(item.price)}</div>
|
return <div className="card-price">{formatPrice(item.price)}</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -458,3 +458,131 @@
|
|||||||
transform: scale(0.95);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import './WishlistForm.css'
|
|||||||
const API_URL = '/api/wishlist'
|
const API_URL = '/api/wishlist'
|
||||||
const TASKS_API_URL = '/api/tasks'
|
const TASKS_API_URL = '/api/tasks'
|
||||||
const PROJECTS_API_URL = '/projects'
|
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 { authFetch } = useAuth()
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [price, setPrice] = useState('')
|
const [price, setPrice] = useState('')
|
||||||
@@ -29,6 +30,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex }) {
|
|||||||
const [toastMessage, setToastMessage] = useState(null)
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
const [loadingWishlist, setLoadingWishlist] = useState(false)
|
const [loadingWishlist, setLoadingWishlist] = useState(false)
|
||||||
const [fetchingMetadata, setFetchingMetadata] = useState(false)
|
const [fetchingMetadata, setFetchingMetadata] = useState(false)
|
||||||
|
const [restoredFromSession, setRestoredFromSession] = useState(false) // Флаг восстановления из sessionStorage
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
// Загрузка задач и проектов
|
// Загрузка задач и проектов
|
||||||
@@ -57,13 +59,19 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex }) {
|
|||||||
|
|
||||||
// Загрузка желания при редактировании или сброс формы при создании
|
// Загрузка желания при редактировании или сброс формы при создании
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Пропускаем загрузку, если состояние было восстановлено из sessionStorage
|
||||||
|
if (restoredFromSession) {
|
||||||
|
console.log('[WishlistForm] Skipping loadWishlist - restored from session')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (wishlistId !== undefined && wishlistId !== null && tasks.length > 0 && projects.length > 0) {
|
if (wishlistId !== undefined && wishlistId !== null && tasks.length > 0 && projects.length > 0) {
|
||||||
loadWishlist()
|
loadWishlist()
|
||||||
} else if (wishlistId === undefined || wishlistId === null) {
|
} else if (wishlistId === undefined || wishlistId === null) {
|
||||||
// Сбрасываем форму при создании новой задачи
|
// Сбрасываем форму при создании новой задачи
|
||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
}, [wishlistId, tasks, projects])
|
}, [wishlistId, tasks, projects, restoredFromSession])
|
||||||
|
|
||||||
// Сброс формы при размонтировании компонента или при изменении wishlistId на undefined
|
// Сброс формы при размонтировании компонента или при изменении wishlistId на undefined
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -84,6 +92,89 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex }) {
|
|||||||
}
|
}
|
||||||
}, [editConditionIndex, unlockConditions])
|
}, [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 () => {
|
const loadWishlist = async () => {
|
||||||
setLoadingWishlist(true)
|
setLoadingWishlist(true)
|
||||||
try {
|
try {
|
||||||
@@ -356,6 +447,30 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex }) {
|
|||||||
setUnlockConditions(unlockConditions.filter((_, i) => i !== index))
|
setUnlockConditions(unlockConditions.filter((_, i) => i !== index))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обработчик для создания задачи из ConditionForm
|
||||||
|
const handleCreateTaskFromCondition = () => {
|
||||||
|
// Сохранить текущее состояние формы
|
||||||
|
const stateToSave = {
|
||||||
|
name,
|
||||||
|
price,
|
||||||
|
link,
|
||||||
|
imageUrl,
|
||||||
|
unlockConditions,
|
||||||
|
pendingConditionType: 'task_completion',
|
||||||
|
editingConditionIndex,
|
||||||
|
}
|
||||||
|
console.log('[WishlistForm] Saving state and navigating to task-form:', stateToSave)
|
||||||
|
sessionStorage.setItem(WISHLIST_FORM_STATE_KEY, JSON.stringify(stateToSave))
|
||||||
|
|
||||||
|
// Навигация на форму создания задачи
|
||||||
|
const navParams = {
|
||||||
|
returnTo: 'wishlist-form',
|
||||||
|
returnWishlistId: wishlistId,
|
||||||
|
}
|
||||||
|
console.log('[WishlistForm] Navigation params:', navParams)
|
||||||
|
onNavigate?.('task-form', navParams)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -640,6 +755,8 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex }) {
|
|||||||
onSubmit={handleConditionSubmit}
|
onSubmit={handleConditionSubmit}
|
||||||
onCancel={handleConditionCancel}
|
onCancel={handleConditionCancel}
|
||||||
editingCondition={editingConditionIndex !== null ? unlockConditions[editingConditionIndex] : null}
|
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 [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 [projectId, setProjectId] = useState(editingCondition?.project_id?.toString() || '')
|
||||||
const [requiredPoints, setRequiredPoints] = useState(editingCondition?.required_points?.toString() || '')
|
const [requiredPoints, setRequiredPoints] = useState(editingCondition?.required_points?.toString() || '')
|
||||||
const [startDate, setStartDate] = useState(editingCondition?.start_date || '')
|
const [startDate, setStartDate] = useState(editingCondition?.start_date || '')
|
||||||
|
|
||||||
const isEditing = editingCondition !== null
|
const isEditing = editingCondition !== null
|
||||||
|
|
||||||
|
// Автоподстановка новой задачи
|
||||||
|
useEffect(() => {
|
||||||
|
if (preselectedTaskId && !editingCondition) {
|
||||||
|
setType('task_completion')
|
||||||
|
setTaskId(preselectedTaskId)
|
||||||
|
}
|
||||||
|
}, [preselectedTaskId, editingCondition])
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation() // Предотвращаем всплытие события
|
e.stopPropagation() // Предотвращаем всплытие события
|
||||||
|
|
||||||
// Валидация
|
// Валидация
|
||||||
if (type === 'task_completion' && !taskId) {
|
if (type === 'task_completion' && (!taskId || taskId === null)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (type === 'project_points' && (!projectId || !requiredPoints)) {
|
if (type === 'project_points' && (!projectId || !requiredPoints)) {
|
||||||
@@ -756,7 +1062,7 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition }
|
|||||||
|
|
||||||
const condition = {
|
const condition = {
|
||||||
type,
|
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,
|
project_id: type === 'project_points' ? parseInt(projectId) : null,
|
||||||
required_points: type === 'project_points' ? parseFloat(requiredPoints) : null,
|
required_points: type === 'project_points' ? parseFloat(requiredPoints) : null,
|
||||||
start_date: type === 'project_points' && startDate ? startDate : null,
|
start_date: type === 'project_points' && startDate ? startDate : null,
|
||||||
@@ -764,7 +1070,7 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition }
|
|||||||
onSubmit(condition)
|
onSubmit(condition)
|
||||||
// Сброс формы
|
// Сброс формы
|
||||||
setType('project_points')
|
setType('project_points')
|
||||||
setTaskId('')
|
setTaskId(null)
|
||||||
setProjectId('')
|
setProjectId('')
|
||||||
setRequiredPoints('')
|
setRequiredPoints('')
|
||||||
setStartDate('')
|
setStartDate('')
|
||||||
@@ -790,17 +1096,13 @@ function ConditionForm({ tasks, projects, onSubmit, onCancel, editingCondition }
|
|||||||
{type === 'task_completion' && (
|
{type === 'task_completion' && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Задача</label>
|
<label>Задача</label>
|
||||||
<select
|
<TaskAutocomplete
|
||||||
|
tasks={tasks}
|
||||||
value={taskId}
|
value={taskId}
|
||||||
onChange={(e) => setTaskId(e.target.value)}
|
onChange={(id) => setTaskId(id)}
|
||||||
className="form-input"
|
onCreateTask={onCreateTask}
|
||||||
required
|
preselectedTaskId={preselectedTaskId}
|
||||||
>
|
/>
|
||||||
<option value="">Выберите задачу</option>
|
|
||||||
{tasks.map(task => (
|
|
||||||
<option key={task.id} value={task.id}>{task.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user