diff --git a/VERSION b/VERSION index b6c5265..4d9fbcf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.24.7 +4.25.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index ae79ddb..45daf78 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -330,6 +330,7 @@ type Task struct { Position *int `json:"position,omitempty"` // Position for subtasks // Дополнительные поля для списка задач (без omitempty чтобы всегда передавались) ProjectNames []string `json:"project_names"` + GroupName *string `json:"group_name,omitempty"` // Название группы задачи SubtasksCount int `json:"subtasks_count"` HasProgression bool `json:"has_progression"` AutoComplete bool `json:"auto_complete"` @@ -391,6 +392,7 @@ type TaskRequest struct { RepetitionDate *string `json:"repetition_date,omitempty"` WishlistID *int `json:"wishlist_id,omitempty"` RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями + GroupName *string `json:"group_name,omitempty"` // Название группы задачи Rewards []RewardRequest `json:"rewards,omitempty"` Subtasks []SubtaskRequest `json:"subtasks,omitempty"` // Test-specific fields @@ -464,9 +466,8 @@ type WishlistItem struct { LockedConditionsCount int `json:"locked_conditions_count,omitempty"` // Общее количество заблокированных условий UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"` LinkedTask *LinkedTask `json:"linked_task,omitempty"` - TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания - ProjectID *int `json:"project_id,omitempty"` // ID проекта, к которому принадлежит желание - ProjectName *string `json:"project_name,omitempty"` // Название проекта + TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания + GroupName *string `json:"group_name,omitempty"` // Название группы желания } type UnlockConditionDisplay struct { @@ -494,7 +495,7 @@ type WishlistRequest struct { Name string `json:"name"` Price *float64 `json:"price,omitempty"` Link *string `json:"link,omitempty"` - ProjectID *int `json:"project_id,omitempty"` // ID проекта, к которому принадлежит желание + GroupName *string `json:"group_name,omitempty"` // Название группы желания UnlockConditions []UnlockConditionRequest `json:"unlock_conditions,omitempty"` } @@ -4227,6 +4228,9 @@ func main() { protected.HandleFunc("/api/wishlist/{id}/uncomplete", app.uncompleteWishlistHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist/{id}/copy", app.copyWishlistHandler).Methods("POST", "OPTIONS") + // Group suggestions + protected.HandleFunc("/api/group-suggestions", app.getGroupSuggestionsHandler).Methods("GET", "OPTIONS") + // Admin operations protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS") @@ -5868,6 +5872,11 @@ func (a *App) moveProjectHandler(w http.ResponseWriter, r *http.Request) { return } + // Обновляем MV для групповых саджестов (имя проекта изменилось) + if err := a.refreshGroupSuggestionsMV(); err != nil { + log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Project renamed successfully", @@ -5949,8 +5958,10 @@ func (a *App) moveProjectHandler(w http.ResponseWriter, r *http.Request) { return } - // MV обновляется только по крону в понедельник в 6:00 утра - // Данные текущей недели берутся напрямую из nodes + // Обновляем MV для групповых саджестов (проект переименован или удалён) + if err := a.refreshGroupSuggestionsMV(); err != nil { + log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ @@ -6027,8 +6038,10 @@ func (a *App) deleteProjectHandler(w http.ResponseWriter, r *http.Request) { return } - // MV обновляется только по крону в понедельник в 6:00 утра - // Данные текущей недели берутся напрямую из nodes + // Обновляем MV для групповых саджестов (проект удалён) + if err := a.refreshGroupSuggestionsMV(); err != nil { + log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ @@ -6094,6 +6107,11 @@ func (a *App) createProjectHandler(w http.ResponseWriter, r *http.Request) { return } + // Обновляем MV для групповых саджестов (проекты попадают в саджесты) + if err := a.refreshGroupSuggestionsMV(); err != nil { + log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Project created successfully", @@ -7333,6 +7351,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { t.wishlist_id, t.config_id, t.reward_policy, + t.group_name, COALESCE(( SELECT COUNT(*) FROM tasks st @@ -7389,6 +7408,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { var wishlistID sql.NullInt64 var configID sql.NullInt64 var rewardPolicy sql.NullString + var groupName sql.NullString var projectNames pq.StringArray var subtaskProjectNames pq.StringArray var autoComplete bool @@ -7405,6 +7425,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { &wishlistID, &configID, &rewardPolicy, + &groupName, &task.SubtasksCount, &projectNames, &subtaskProjectNames, @@ -7444,6 +7465,10 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { if rewardPolicy.Valid { task.RewardPolicy = &rewardPolicy.String } + if groupName.Valid && groupName.String != "" { + groupNameVal := groupName.String + task.GroupName = &groupNameVal + } task.AutoComplete = autoComplete // Объединяем проекты из основной задачи и подзадач @@ -7504,6 +7529,7 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { var wishlistID sql.NullInt64 var configID sql.NullInt64 var rewardPolicy sql.NullString + var groupName sql.NullString // Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL var repetitionPeriodStr string @@ -7514,11 +7540,12 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { COALESCE(repetition_date, '') as repetition_date, wishlist_id, config_id, - reward_policy + reward_policy, + group_name FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE `, taskID, userID).Scan( - &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID, &rewardPolicy, + &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID, &rewardPolicy, &groupName, ) log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr) @@ -7578,6 +7605,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { if rewardPolicy.Valid { task.RewardPolicy = &rewardPolicy.String } + if groupName.Valid && groupName.String != "" { + groupNameVal := groupName.String + task.GroupName = &groupNameVal + } // Получаем награды основной задачи rewards := make([]Reward, 0) @@ -8111,11 +8142,11 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { now := time.Now().In(loc) insertSQL = ` - INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id, reward_policy) - VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, $6, 0, FALSE, $7, $8) + INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id, reward_policy, group_name) + VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, $6, 0, FALSE, $7, $8, $9) RETURNING id ` - insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue, now, wishlistIDValue, rewardPolicyValue} + insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue, now, wishlistIDValue, rewardPolicyValue, req.GroupName} } else if repetitionDate.Valid { // Вычисляем next_show_at для задачи с repetition_date @@ -8130,26 +8161,26 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now().In(loc)) if nextShowAt != nil { insertSQL = ` - INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id, reward_policy) - VALUES ($1, $2, $3, $4, NULL, $5, $6, 0, FALSE, $7, $8) - RETURNING id - ` - insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, wishlistIDValue, rewardPolicyValue} + INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id, reward_policy, group_name) + VALUES ($1, $2, $3, $4, NULL, $5, $6, 0, FALSE, $7, $8, $9) + RETURNING id + ` + insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, wishlistIDValue, rewardPolicyValue, req.GroupName} } else { insertSQL = ` - INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, wishlist_id, reward_policy) - VALUES ($1, $2, $3, $4, NULL, $5, 0, FALSE, $6, $7) - RETURNING id - ` - insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, wishlistIDValue, rewardPolicyValue} + INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, wishlist_id, reward_policy, group_name) + VALUES ($1, $2, $3, $4, NULL, $5, 0, FALSE, $6, $7, $8) + RETURNING id + ` + insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, wishlistIDValue, rewardPolicyValue, req.GroupName} } } else { insertSQL = ` - INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, wishlist_id, reward_policy) - VALUES ($1, $2, $3, $4, NULL, NULL, 0, FALSE, $5, $6) + INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, wishlist_id, reward_policy, group_name) + VALUES ($1, $2, $3, $4, NULL, NULL, 0, FALSE, $5, $6, $7) RETURNING id ` - insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, wishlistIDValue, rewardPolicyValue} + insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, wishlistIDValue, rewardPolicyValue, req.GroupName} } err = tx.QueryRow(insertSQL, insertArgs...).Scan(&taskID) @@ -8316,6 +8347,13 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { return } + // Обновляем MV для групповых саджестов + if req.GroupName != nil && *req.GroupName != "" { + if err := a.refreshGroupSuggestionsMV(); err != nil { + log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) + } + } + // Возвращаем созданную задачу var createdTask Task var lastCompletedAt sql.NullString @@ -8530,35 +8568,35 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) { now := time.Now().In(loc) updateSQL = ` UPDATE tasks - SET name = $1, reward_message = $2, progression_base = $3, repetition_period = $4::INTERVAL, repetition_date = NULL, next_show_at = $5, wishlist_id = $6, reward_policy = $7 - WHERE id = $8 + SET name = $1, reward_message = $2, progression_base = $3, repetition_period = $4::INTERVAL, repetition_date = NULL, next_show_at = $5, wishlist_id = $6, reward_policy = $7, group_name = $8 + WHERE id = $9 ` - updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, now, newWishlistID, rewardPolicyValue, taskID} + updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, now, newWishlistID, rewardPolicyValue, req.GroupName, taskID} } else if repetitionDate.Valid { // Вычисляем next_show_at для задачи с repetition_date nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now().In(loc)) if nextShowAt != nil { updateSQL = ` - UPDATE tasks - SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, next_show_at = $5, wishlist_id = $6, reward_policy = $7 - WHERE id = $8 - ` - updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, newWishlistID, rewardPolicyValue, taskID} + UPDATE tasks + SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, next_show_at = $5, wishlist_id = $6, reward_policy = $7, group_name = $8 + WHERE id = $9 + ` + updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, newWishlistID, rewardPolicyValue, req.GroupName, taskID} } else { updateSQL = ` - UPDATE tasks - SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, wishlist_id = $5, reward_policy = $6 - WHERE id = $7 - ` - updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, newWishlistID, rewardPolicyValue, taskID} + UPDATE tasks + SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, wishlist_id = $5, reward_policy = $6, group_name = $7 + WHERE id = $8 + ` + updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, newWishlistID, rewardPolicyValue, req.GroupName, taskID} } } else { updateSQL = ` UPDATE tasks - SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = NULL, next_show_at = NULL, wishlist_id = $4, reward_policy = $5 - WHERE id = $6 + SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = NULL, next_show_at = NULL, wishlist_id = $4, reward_policy = $5, group_name = $6 + WHERE id = $7 ` - updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, newWishlistID, rewardPolicyValue, taskID} + updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, newWishlistID, rewardPolicyValue, req.GroupName, taskID} } _, err = tx.Exec(updateSQL, updateArgs...) @@ -8877,6 +8915,13 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) { return } + // Обновляем MV для групповых саджестов + if req.GroupName != nil && *req.GroupName != "" { + if err := a.refreshGroupSuggestionsMV(); err != nil { + log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) + } + } + // Возвращаем обновленную задачу var updatedTask Task var lastCompletedAt sql.NullString @@ -10347,8 +10392,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) wi.image_path, wi.link, wi.completed, - wi.project_id AS item_project_id, - wp.name AS item_project_name, + wi.group_name, wc.id AS condition_id, wc.display_order, wc.task_condition_id, @@ -10361,7 +10405,6 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) sc.required_points, sc.start_date FROM wishlist_items wi - LEFT JOIN projects wp ON wi.project_id = wp.id AND wp.deleted = FALSE LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE @@ -10388,8 +10431,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) var price sql.NullFloat64 var imagePath, link sql.NullString var completed bool - var itemProjectID sql.NullInt64 - var itemProjectName sql.NullString + var groupName sql.NullString var conditionID, displayOrder sql.NullInt64 var taskConditionID, scoreConditionID sql.NullInt64 @@ -10403,7 +10445,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) err := rows.Scan( &itemID, &name, &price, &imagePath, &link, &completed, - &itemProjectID, &itemProjectName, + &groupName, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID, &taskID, &taskName, @@ -10434,13 +10476,9 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) l := link.String item.Link = &l } - if itemProjectID.Valid { - projectIDVal := int(itemProjectID.Int64) - item.ProjectID = &projectIDVal - } - if itemProjectName.Valid { - projectNameVal := itemProjectName.String - item.ProjectName = &projectNameVal + if groupName.Valid && groupName.String != "" { + groupNameVal := groupName.String + item.GroupName = &groupNameVal } itemsMap[itemID] = item } @@ -11108,10 +11146,10 @@ func (a *App) createWishlistHandler(w http.ResponseWriter, r *http.Request) { var wishlistID int err = tx.QueryRow(` - INSERT INTO wishlist_items (user_id, author_id, name, price, link, project_id, completed, deleted) + INSERT INTO wishlist_items (user_id, author_id, name, price, link, group_name, completed, deleted) VALUES ($1, $1, $2, $3, $4, $5, FALSE, FALSE) RETURNING id - `, userID, strings.TrimSpace(req.Name), req.Price, req.Link, req.ProjectID).Scan(&wishlistID) + `, userID, strings.TrimSpace(req.Name), req.Price, req.Link, req.GroupName).Scan(&wishlistID) if err != nil { log.Printf("Error creating wishlist item: %v", err) @@ -11141,6 +11179,13 @@ func (a *App) createWishlistHandler(w http.ResponseWriter, r *http.Request) { } log.Printf("createWishlistHandler: transaction committed successfully") + // Обновляем MV для групповых саджестов + if req.GroupName != nil && *req.GroupName != "" { + if err := a.refreshGroupSuggestionsMV(); err != nil { + log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) + } + } + // Получаем созданное желание с условиями items, err := a.getWishlistItemsWithConditions(userID, false) if err != nil { @@ -11322,8 +11367,7 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { wi.image_path, wi.link, wi.completed, - wi.project_id AS item_project_id, - wp.name AS item_project_name, + wi.group_name, wc.id AS condition_id, wc.display_order, wc.task_condition_id, @@ -11337,7 +11381,6 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { sc.required_points, sc.start_date FROM wishlist_items wi - LEFT JOIN projects wp ON wi.project_id = wp.id AND wp.deleted = FALSE LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE @@ -11364,8 +11407,7 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { var imagePath sql.NullString var link sql.NullString var completed bool - var itemProjectID sql.NullInt64 - var itemProjectName sql.NullString + var groupName sql.NullString var conditionID sql.NullInt64 var displayOrder sql.NullInt64 var taskConditionID sql.NullInt64 @@ -11380,7 +11422,7 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { var startDate sql.NullTime err := rows.Scan( - &itemID, &name, &price, &imagePath, &link, &completed, &itemProjectID, &itemProjectName, + &itemID, &name, &price, &imagePath, &link, &completed, &groupName, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID, &taskID, &taskName, &taskNextShowAt, &projectID, &projectName, &requiredPoints, &startDate, ) @@ -11410,13 +11452,9 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { if link.Valid { item.Link = &link.String } - if itemProjectID.Valid { - projectIDVal := int(itemProjectID.Int64) - item.ProjectID = &projectIDVal - } - if itemProjectName.Valid { - projectNameVal := itemProjectName.String - item.ProjectName = &projectNameVal + if groupName.Valid && groupName.String != "" { + groupNameVal := groupName.String + item.GroupName = &groupNameVal } itemsMap[itemID] = item } @@ -11679,9 +11717,9 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { // Обновляем желание (не проверяем user_id в WHERE, так как доступ уже проверен выше) _, err = tx.Exec(` UPDATE wishlist_items - SET name = $1, price = $2, link = $3, project_id = $4, updated_at = NOW() + SET name = $1, price = $2, link = $3, group_name = $4, updated_at = NOW() WHERE id = $5 - `, strings.TrimSpace(req.Name), req.Price, req.Link, req.ProjectID, itemID) + `, strings.TrimSpace(req.Name), req.Price, req.Link, req.GroupName, itemID) if err != nil { log.Printf("Error updating wishlist item: %v", err) @@ -11703,6 +11741,13 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { return } + // Обновляем MV для групповых саджестов + if req.GroupName != nil && *req.GroupName != "" { + if err := a.refreshGroupSuggestionsMV(); err != nil { + log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) + } + } + // Получаем обновлённое желание через getWishlistItemHandler логику // Используем тот же запрос, что и в getWishlistItemHandler query := ` @@ -11713,6 +11758,7 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { wi.image_path, wi.link, wi.completed, + wi.group_name, wc.id AS condition_id, wc.display_order, wc.task_condition_id, @@ -11752,6 +11798,7 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { var imagePath sql.NullString var link sql.NullString var completed bool + var groupName sql.NullString var conditionID sql.NullInt64 var displayOrder sql.NullInt64 var taskConditionID sql.NullInt64 @@ -11765,7 +11812,7 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { var startDate sql.NullTime err := rows.Scan( - &itemID, &name, &price, &imagePath, &link, &completed, + &itemID, &name, &price, &imagePath, &link, &completed, &groupName, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID, &taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate, ) @@ -11802,6 +11849,10 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { if link.Valid { item.Link = &link.String } + if groupName.Valid && groupName.String != "" { + groupNameVal := groupName.String + item.GroupName = &groupNameVal + } itemsMap[itemID] = item } @@ -12416,11 +12467,12 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) { var ownerID int var boardID sql.NullInt64 var authorID sql.NullInt64 + var groupName sql.NullString err = a.DB.QueryRow(` - SELECT user_id, name, price, link, image_path, board_id, author_id + SELECT user_id, name, price, link, image_path, board_id, author_id, group_name FROM wishlist_items WHERE id = $1 AND deleted = FALSE - `, itemID).Scan(&ownerID, &name, &price, &link, &imagePath, &boardID, &authorID) + `, itemID).Scan(&ownerID, &name, &price, &link, &imagePath, &boardID, &authorID, &groupName) if err == sql.ErrNoRows || ownerID != userID { sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) @@ -12512,7 +12564,7 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) { } // Определяем значения для board_id и author_id - var boardIDVal, authorIDVal interface{} + var boardIDVal, authorIDVal, groupNameVal interface{} if boardID.Valid { boardIDVal = int(boardID.Int64) } @@ -12522,12 +12574,15 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) { // Если author_id не был установлен, используем текущего пользователя authorIDVal = userID } + if groupName.Valid { + groupNameVal = groupName.String + } err = tx.QueryRow(` - INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, completed, deleted) - VALUES ($1, $2, $3, $4, $5, $6, FALSE, FALSE) + INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, group_name, completed, deleted) + VALUES ($1, $2, $3, $4, $5, $6, $7, FALSE, FALSE) RETURNING id - `, ownerID, boardIDVal, authorIDVal, name+" (копия)", priceVal, linkVal).Scan(&newWishlistID) + `, ownerID, boardIDVal, authorIDVal, name+" (копия)", priceVal, linkVal, groupNameVal).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) @@ -12615,6 +12670,13 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) { return } + // Обновляем MV для групповых саджестов + if groupName.Valid && groupName.String != "" { + if err := a.refreshGroupSuggestionsMV(); err != nil { + log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) + } + } + // Получаем созданное желание с условиями items, err := a.getWishlistItemsWithConditions(userID, false) if err != nil { @@ -13844,8 +13906,7 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, wi.image_path, wi.link, wi.completed, - wi.project_id AS item_project_id, - wp.name AS item_project_name, + wi.group_name, COALESCE(wi.author_id, wi.user_id) AS item_owner_id, wc.id AS condition_id, wc.display_order, @@ -13859,7 +13920,6 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, sc.required_points, sc.start_date FROM wishlist_items wi - LEFT JOIN projects wp ON wi.project_id = wp.id AND wp.deleted = FALSE LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE @@ -13886,8 +13946,7 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, var imagePath sql.NullString var link sql.NullString var completed bool - var itemProjectID sql.NullInt64 - var itemProjectName sql.NullString + var groupName sql.NullString var itemOwnerID sql.NullInt64 var conditionID sql.NullInt64 var displayOrder sql.NullInt64 @@ -13902,7 +13961,7 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, var startDate sql.NullTime err := rows.Scan( - &itemID, &name, &price, &imagePath, &link, &completed, &itemProjectID, &itemProjectName, &itemOwnerID, + &itemID, &name, &price, &imagePath, &link, &completed, &groupName, &itemOwnerID, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID, &taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate, ) @@ -13932,13 +13991,9 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, if link.Valid { item.Link = &link.String } - if itemProjectID.Valid { - projectIDVal := int(itemProjectID.Int64) - item.ProjectID = &projectIDVal - } - if itemProjectName.Valid { - projectNameVal := itemProjectName.String - item.ProjectName = &projectNameVal + if groupName.Valid && groupName.String != "" { + groupNameVal := groupName.String + item.GroupName = &groupNameVal } itemsMap[itemID] = item } @@ -14211,10 +14266,10 @@ func (a *App) createBoardItemHandler(w http.ResponseWriter, r *http.Request) { var itemID int err = tx.QueryRow(` - INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, project_id, completed, deleted) + INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, group_name, completed, deleted) VALUES ($1, $2, $3, $4, $5, $6, $7, FALSE, FALSE) RETURNING id - `, ownerID, boardID, userID, strings.TrimSpace(req.Name), req.Price, req.Link, req.ProjectID).Scan(&itemID) + `, ownerID, boardID, userID, strings.TrimSpace(req.Name), req.Price, req.Link, req.GroupName).Scan(&itemID) if err != nil { log.Printf("createBoardItemHandler: Error creating board item: %v", err) @@ -14244,6 +14299,13 @@ func (a *App) createBoardItemHandler(w http.ResponseWriter, r *http.Request) { return } + // Обновляем MV для групповых саджестов + if req.GroupName != nil && *req.GroupName != "" { + if err := a.refreshGroupSuggestionsMV(); err != nil { + log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) + } + } + // Возвращаем созданное желание w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) @@ -15521,3 +15583,57 @@ func decodeHTMLEntities(s string) string { } return s } + +// refreshGroupSuggestionsMV обновляет materialized view для групповых саджестов +func (a *App) refreshGroupSuggestionsMV() error { + _, err := a.DB.Exec("REFRESH MATERIALIZED VIEW CONCURRENTLY user_group_suggestions_mv") + if err != nil { + log.Printf("Error refreshing user_group_suggestions_mv: %v", err) + return err + } + return nil +} + +// getGroupSuggestionsHandler возвращает список уникальных имён групп для текущего пользователя +func (a *App) getGroupSuggestionsHandler(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 + } + + query := ` + SELECT DISTINCT group_name + FROM user_group_suggestions_mv + WHERE user_id = $1 + ORDER BY group_name + ` + + rows, err := a.DB.Query(query, userID) + if err != nil { + log.Printf("Error querying group suggestions: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error querying group suggestions: %v", err), http.StatusInternalServerError) + return + } + defer rows.Close() + + groups := make([]string, 0) + for rows.Next() { + var groupName string + if err := rows.Scan(&groupName); err != nil { + log.Printf("Error scanning group name: %v", err) + continue + } + groups = append(groups, groupName) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(groups) +} diff --git a/play-life-backend/migrations/000014_add_group_name.down.sql b/play-life-backend/migrations/000014_add_group_name.down.sql new file mode 100644 index 0000000..d3e3d79 --- /dev/null +++ b/play-life-backend/migrations/000014_add_group_name.down.sql @@ -0,0 +1,36 @@ +-- Migration: Remove group_name field from wishlist_items and tasks tables +-- Date: 2026-02-XX +-- +-- This migration reverses the changes made in 000014_add_group_name.up.sql + +-- Step 1: Drop materialized view +DROP MATERIALIZED VIEW IF EXISTS user_group_suggestions_mv; + +-- Step 2: Drop indexes on group_name +DROP INDEX IF EXISTS idx_tasks_group_name; +DROP INDEX IF EXISTS idx_wishlist_items_group_name; + +-- Step 3: Remove group_name from tasks +ALTER TABLE tasks +DROP COLUMN group_name; + +-- Step 4: Add back project_id to wishlist_items +ALTER TABLE wishlist_items +ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL; + +-- Step 5: Try to restore project_id from group_name (if possible) +-- Note: This is best-effort, as group_name might not match project names exactly +UPDATE wishlist_items wi +SET project_id = p.id +FROM projects p +WHERE wi.group_name = p.name + AND wi.group_name IS NOT NULL + AND wi.group_name != '' + AND p.deleted = FALSE; + +-- Step 6: Create index on project_id +CREATE INDEX idx_wishlist_items_project_id ON wishlist_items(project_id); + +-- Step 7: Remove group_name from wishlist_items +ALTER TABLE wishlist_items +DROP COLUMN group_name; diff --git a/play-life-backend/migrations/000014_add_group_name.up.sql b/play-life-backend/migrations/000014_add_group_name.up.sql new file mode 100644 index 0000000..ca9512d --- /dev/null +++ b/play-life-backend/migrations/000014_add_group_name.up.sql @@ -0,0 +1,60 @@ +-- Migration: Add group_name field to wishlist_items and tasks tables +-- Date: 2026-02-XX +-- +-- This migration: +-- 1. Adds group_name field to wishlist_items (replacing project_id) +-- 2. Migrates existing data from project_id to group_name +-- 3. Removes project_id column from wishlist_items +-- 4. Adds group_name field to tasks +-- 5. Creates materialized view for group suggestions + +-- Step 1: Add group_name to wishlist_items +ALTER TABLE wishlist_items +ADD COLUMN group_name VARCHAR(255); + +-- Step 2: Migrate existing data from project_id to group_name +UPDATE wishlist_items wi +SET group_name = p.name +FROM projects p +WHERE wi.project_id = p.id AND wi.project_id IS NOT NULL; + +-- Step 3: Remove project_id column and its index +DROP INDEX IF EXISTS idx_wishlist_items_project_id; +ALTER TABLE wishlist_items +DROP COLUMN project_id; + +-- Step 4: Add group_name to tasks +ALTER TABLE tasks +ADD COLUMN group_name VARCHAR(255); + +-- Step 5: Create indexes on group_name +CREATE INDEX idx_wishlist_items_group_name ON wishlist_items(group_name) WHERE group_name IS NOT NULL; +CREATE INDEX idx_tasks_group_name ON tasks(group_name) WHERE group_name IS NOT NULL; + +-- Step 6: Create materialized view for group suggestions +CREATE MATERIALIZED VIEW user_group_suggestions_mv AS +SELECT DISTINCT user_id, group_name FROM ( + -- Желания пользователя (собственные) + SELECT wi.user_id, wi.group_name FROM wishlist_items wi + WHERE wi.deleted = FALSE AND wi.group_name IS NOT NULL AND wi.group_name != '' + UNION + -- Желания с досок, на которых пользователь участник + SELECT wbm.user_id, wi.group_name FROM wishlist_items wi + JOIN wishlist_board_members wbm ON wi.board_id = wbm.board_id + WHERE wi.deleted = FALSE AND wi.group_name IS NOT NULL AND wi.group_name != '' + UNION + -- Задачи пользователя + SELECT t.user_id, t.group_name FROM tasks t + WHERE t.deleted = FALSE AND t.group_name IS NOT NULL AND t.group_name != '' + UNION + -- Имена проектов пользователя + SELECT p.user_id, p.name FROM projects p + WHERE p.deleted = FALSE +) sub; + +-- Step 7: Create unique index for CONCURRENT refresh +CREATE UNIQUE INDEX idx_user_group_suggestions_mv_user_group ON user_group_suggestions_mv(user_id, group_name); + +COMMENT ON COLUMN wishlist_items.group_name IS 'Group name for wishlist item (free text, replaces project_id)'; +COMMENT ON COLUMN tasks.group_name IS 'Group name for task (free text)'; +COMMENT ON MATERIALIZED VIEW user_group_suggestions_mv IS 'Materialized view for group name suggestions per user'; diff --git a/play-life-web/package.json b/play-life-web/package.json index 81064dd..faddf70 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "4.24.7", + "version": "4.25.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/Buttons.css b/play-life-web/src/components/Buttons.css index 8bf4488..5b6d693 100644 --- a/play-life-web/src/components/Buttons.css +++ b/play-life-web/src/components/Buttons.css @@ -17,6 +17,11 @@ cursor: pointer; transition: all 0.2s; flex: 1; + height: 44px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; } .submit-button:hover:not(:disabled) { @@ -32,7 +37,7 @@ .delete-button { background: #ef4444; color: white; - padding: 0.75rem; + padding: 0; border: none; border-radius: 0.375rem; font-size: 1rem; @@ -44,6 +49,8 @@ justify-content: center; min-width: 44px; width: 44px; + height: 44px; + box-sizing: border-box; } .delete-button:hover:not(:disabled) { diff --git a/play-life-web/src/components/TaskForm.css b/play-life-web/src/components/TaskForm.css index 0736567..bd1abfd 100644 --- a/play-life-web/src/components/TaskForm.css +++ b/play-life-web/src/components/TaskForm.css @@ -546,3 +546,65 @@ color: #6b7280; font-style: italic; } + +/* Group Autocomplete */ +.group-autocomplete { + position: relative; +} + +.group-autocomplete-input-wrapper { + position: relative; +} + +.group-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; +} + +.group-autocomplete-clear:hover { + color: #6b7280; + background: #f3f4f6; +} + +.group-autocomplete-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + 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; +} + +.group-autocomplete-item { + padding: 12px 14px; + cursor: pointer; + font-size: 14px; + color: #374151; + border-bottom: 1px solid #f3f4f6; + transition: background 0.1s; +} + +.group-autocomplete-item:last-child { + border-bottom: none; +} + +.group-autocomplete-item:hover, +.group-autocomplete-item.highlighted { + background: #f3f4f6; +} diff --git a/play-life-web/src/components/TaskForm.jsx b/play-life-web/src/components/TaskForm.jsx index 5cb9548..7db3a63 100644 --- a/play-life-web/src/components/TaskForm.jsx +++ b/play-life-web/src/components/TaskForm.jsx @@ -19,6 +19,8 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa const [rewards, setRewards] = useState([]) const [subtasks, setSubtasks] = useState([]) const [projects, setProjects] = useState([]) + const [groupName, setGroupName] = useState('') + const [groupSuggestions, setGroupSuggestions] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') // Только для валидации const [toastMessage, setToastMessage] = useState(null) @@ -49,7 +51,23 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa } } loadProjects() - }, []) + }, [authFetch]) + + // Загрузка саджестов групп + useEffect(() => { + const loadGroupSuggestions = async () => { + try { + const response = await authFetch('/api/group-suggestions') + if (response.ok) { + const data = await response.json() + setGroupSuggestions(Array.isArray(data) ? data : []) + } + } catch (err) { + console.error('Error loading group suggestions:', err) + } + } + loadGroupSuggestions() + }, [authFetch]) // Загрузка словарей для тестов useEffect(() => { @@ -350,6 +368,13 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa } else { setRewardPolicy('personal') // Значение по умолчанию } + + // Загружаем группу + if (data.task.group_name) { + setGroupName(data.task.group_name) + } else { + setGroupName('') + } } else { setCurrentWishlistId(null) setWishlistInfo(null) @@ -684,6 +709,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa // Отправляем reward_policy если задача связана с желанием // Проверяем currentWishlistId или wishlistInfo, так как currentWishlistId устанавливается при загрузке задачи reward_policy: (wishlistInfo || currentWishlistId) ? rewardPolicy : undefined, + group_name: groupName.trim() || null, rewards: rewards.map(r => ({ position: r.position, project_name: r.project_name.trim(), @@ -833,6 +859,15 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa /> +
+ + +
+ {/* Информация о связанном желании */} {wishlistInfo && (
@@ -1259,5 +1294,139 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa ) } +// Компонент автодополнения для выбора группы +function GroupAutocomplete({ suggestions, value, onChange }) { + const [inputValue, setInputValue] = useState('') + const [isOpen, setIsOpen] = useState(false) + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const wrapperRef = useRef(null) + const inputRef = useRef(null) + + // При изменении value - обновить inputValue + useEffect(() => { + setInputValue(value || '') + }, [value]) + + // Фильтрация саджестов + const filteredSuggestions = inputValue.trim() + ? suggestions.filter(group => + group.toLowerCase().includes(inputValue.toLowerCase()) + ) + : suggestions + + // Закрытие при клике снаружи + useEffect(() => { + const handleClickOutside = (e) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target)) { + setIsOpen(false) + // Восстанавливаем значение + setInputValue(value || '') + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [value]) + + const handleInputChange = (e) => { + const newValue = e.target.value + setInputValue(newValue) + setIsOpen(true) + setHighlightedIndex(-1) + onChange(newValue) + } + + const handleSelectGroup = (group) => { + onChange(group) + setInputValue(group) + setIsOpen(false) + setHighlightedIndex(-1) + } + + const handleKeyDown = (e) => { + if (!isOpen) { + if (e.key === 'ArrowDown' || e.key === 'Enter') { + setIsOpen(true) + e.preventDefault() + } + return + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setHighlightedIndex(prev => + prev < filteredSuggestions.length - 1 ? prev + 1 : prev + ) + break + case 'ArrowUp': + e.preventDefault() + setHighlightedIndex(prev => prev > 0 ? prev - 1 : -1) + break + case 'Enter': + e.preventDefault() + if (highlightedIndex >= 0 && filteredSuggestions[highlightedIndex]) { + handleSelectGroup(filteredSuggestions[highlightedIndex]) + } + break + case 'Escape': + setIsOpen(false) + setInputValue(value || '') + break + } + } + + const handleFocus = () => { + setIsOpen(true) + } + + return ( +
+
+ + {inputValue && ( + + )} +
+ + {isOpen && filteredSuggestions.length > 0 && ( +
+ {filteredSuggestions.map((group, index) => ( +
handleSelectGroup(group)} + onMouseEnter={() => setHighlightedIndex(index)} + > + {group} +
+ ))} +
+ )} +
+ ) +} + export default TaskForm diff --git a/play-life-web/src/components/TaskList.css b/play-life-web/src/components/TaskList.css index 1fc74dd..ac690da 100644 --- a/play-life-web/src/components/TaskList.css +++ b/play-life-web/src/components/TaskList.css @@ -21,7 +21,7 @@ .task-search-input { width: 100%; - padding: 0.75rem 2.5rem 0.75rem 3rem; + padding: 0.75rem 5rem 0.75rem 3rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; font-size: 1rem; @@ -40,9 +40,34 @@ color: #9ca3af; } +/* Кнопка переключения группировки */ +.task-grouping-toggle { + position: absolute; + right: 1rem; /* Такой же отступ, как у иконки лупы */ + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: #6366f1; + cursor: pointer; + padding: 0.25rem; + border-radius: 0.25rem; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; +} + +.task-grouping-toggle:hover { + background: rgba(99, 102, 241, 0.1); + color: #4f46e5; +} + .task-search-clear { position: absolute; - right: 0.75rem; + right: 0.75rem; /* Остаётся на месте */ top: 50%; transform: translateY(-50%); background: none; diff --git a/play-life-web/src/components/TaskList.jsx b/play-life-web/src/components/TaskList.jsx index 5d480b0..6fda98c 100644 --- a/play-life-web/src/components/TaskList.jsx +++ b/play-life-web/src/components/TaskList.jsx @@ -23,6 +23,25 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry const [isPostponing, setIsPostponing] = useState(false) const [toast, setToast] = useState(null) const [searchQuery, setSearchQuery] = useState('') + // Режим группировки: 'project' (по проекту - по умолчанию) или 'group' (по группе) + const [groupingMode, setGroupingMode] = useState(() => { + // Восстанавливаем из localStorage, по умолчанию 'project' + try { + const saved = localStorage.getItem('taskListGroupingMode') + return saved === 'group' ? 'group' : 'project' + } catch { + return 'project' + } + }) + + // Сохраняем режим группировки в localStorage при изменении + useEffect(() => { + try { + localStorage.setItem('taskListGroupingMode', groupingMode) + } catch { + // Игнорируем ошибки localStorage + } + }, [groupingMode]) useEffect(() => { if (data) { @@ -508,6 +527,16 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry return [] } + // Получаем название группы задачи (для режима группировки по группе) + const getTaskGroupName = (task) => { + // Если у задачи есть group_name - возвращаем его + if (task.group_name && task.group_name.trim()) { + return task.group_name.trim() + } + // Иначе возвращаем null - задача попадёт в "Остальные" + return null + } + // Функция для проверки, является ли период нулевым const isZeroPeriod = (intervalStr) => { if (!intervalStr) return false @@ -548,7 +577,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry return !isNaN(numValue) && numValue === 0 } - // Группируем задачи по проектам + // Группируем задачи по проектам или группам const groupedTasks = useMemo(() => { const today = new Date() today.setHours(0, 0, 0, 0) @@ -563,11 +592,18 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry const groups = {} filteredTasks.forEach(task => { - const projects = getTaskProjects(task) + let groupKeys = [] - // Если у задачи нет проектов, добавляем в группу "Без проекта" - if (projects.length === 0) { - projects.push('Без проекта') + if (groupingMode === 'project') { + // Группировка по проекту (текущее поведение) + groupKeys = getTaskProjects(task) + if (groupKeys.length === 0) { + groupKeys = ['Остальные'] // Было 'Без проекта' + } + } else { + // Группировка по group_name + const groupName = getTaskGroupName(task) + groupKeys = groupName ? [groupName] : ['Остальные'] } // Определяем, в какую группу попадает задача @@ -593,19 +629,19 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry isCompleted = false } - projects.forEach(projectName => { - if (!groups[projectName]) { - groups[projectName] = { + groupKeys.forEach(groupKey => { + if (!groups[groupKey]) { + groups[groupKey] = { notCompleted: [], completed: [] } } if (isCompleted) { - groups[projectName].completed.push(task) + groups[groupKey].completed.push(task) } else { // Бесконечные задачи теперь идут в обычный список - groups[projectName].notCompleted.push(task) + groups[groupKey].notCompleted.push(task) } }) }) @@ -651,7 +687,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry }) return groups - }, [tasks, searchQuery]) + }, [tasks, searchQuery, groupingMode]) // Сортируем проекты: сначала с невыполненными задачами, потом без них // Группа "Без проекта" всегда последняя в своей категории @@ -667,12 +703,12 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry if (!hasNotCompletedA && hasNotCompletedB) return 1 // Если обе группы в одной категории - const isNoProjectA = a === 'Без проекта' - const isNoProjectB = b === 'Без проекта' + const isOthersA = a === 'Остальные' + const isOthersB = b === 'Остальные' - // "Без проекта" всегда последняя в своей категории - if (isNoProjectA && !isNoProjectB) return 1 - if (!isNoProjectA && isNoProjectB) return -1 + // "Остальные" всегда последняя в своей категории + if (isOthersA && !isOthersB) return 1 + if (!isOthersA && isOthersB) return -1 // Остальные группы сортируем по алфавиту return a.localeCompare(b) @@ -953,6 +989,26 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} /> + {/* Кнопка переключения группировки */} + {searchQuery && (
-
- - -
- {error &&
{error}
}
@@ -997,6 +997,140 @@ function DateSelector({ value, onChange, placeholder = "За всё время" ) } +// Компонент автодополнения для выбора группы +function GroupAutocomplete({ suggestions, value, onChange }) { + const [inputValue, setInputValue] = useState('') + const [isOpen, setIsOpen] = useState(false) + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const wrapperRef = useRef(null) + const inputRef = useRef(null) + + // При изменении value - обновить inputValue + useEffect(() => { + setInputValue(value || '') + }, [value]) + + // Фильтрация саджестов + const filteredSuggestions = inputValue.trim() + ? suggestions.filter(group => + group.toLowerCase().includes(inputValue.toLowerCase()) + ) + : suggestions + + // Закрытие при клике снаружи + useEffect(() => { + const handleClickOutside = (e) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target)) { + setIsOpen(false) + // Восстанавливаем значение + setInputValue(value || '') + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [value]) + + const handleInputChange = (e) => { + const newValue = e.target.value + setInputValue(newValue) + setIsOpen(true) + setHighlightedIndex(-1) + onChange(newValue) + } + + const handleSelectGroup = (group) => { + onChange(group) + setInputValue(group) + setIsOpen(false) + setHighlightedIndex(-1) + } + + const handleKeyDown = (e) => { + if (!isOpen) { + if (e.key === 'ArrowDown' || e.key === 'Enter') { + setIsOpen(true) + e.preventDefault() + } + return + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setHighlightedIndex(prev => + prev < filteredSuggestions.length - 1 ? prev + 1 : prev + ) + break + case 'ArrowUp': + e.preventDefault() + setHighlightedIndex(prev => prev > 0 ? prev - 1 : -1) + break + case 'Enter': + e.preventDefault() + if (highlightedIndex >= 0 && filteredSuggestions[highlightedIndex]) { + handleSelectGroup(filteredSuggestions[highlightedIndex]) + } + break + case 'Escape': + setIsOpen(false) + setInputValue(value || '') + break + } + } + + const handleFocus = () => { + setIsOpen(true) + } + + return ( +
+
+ + {inputValue && ( + + )} +
+ + {isOpen && filteredSuggestions.length > 0 && ( +
+ {filteredSuggestions.map((group, index) => ( +
handleSelectGroup(group)} + onMouseEnter={() => setHighlightedIndex(index)} + > + {group} +
+ ))} +
+ )} +
+ ) +} + // Компонент автодополнения для выбора задачи function TaskAutocomplete({ tasks, value, onChange, onCreateTask, preselectedTaskId }) { const [inputValue, setInputValue] = useState('')