4.25.0: Группы вместо проектов для задач и желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m31s

This commit is contained in:
poignatov
2026-02-06 17:42:36 +03:00
parent 0275d9aecf
commit 9f37d8b518
13 changed files with 888 additions and 188 deletions

View File

@@ -1 +1 @@
4.24.7 4.25.0

View File

@@ -330,6 +330,7 @@ type Task struct {
Position *int `json:"position,omitempty"` // Position for subtasks Position *int `json:"position,omitempty"` // Position for subtasks
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались) // Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
ProjectNames []string `json:"project_names"` ProjectNames []string `json:"project_names"`
GroupName *string `json:"group_name,omitempty"` // Название группы задачи
SubtasksCount int `json:"subtasks_count"` SubtasksCount int `json:"subtasks_count"`
HasProgression bool `json:"has_progression"` HasProgression bool `json:"has_progression"`
AutoComplete bool `json:"auto_complete"` AutoComplete bool `json:"auto_complete"`
@@ -391,6 +392,7 @@ type TaskRequest struct {
RepetitionDate *string `json:"repetition_date,omitempty"` RepetitionDate *string `json:"repetition_date,omitempty"`
WishlistID *int `json:"wishlist_id,omitempty"` WishlistID *int `json:"wishlist_id,omitempty"`
RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями
GroupName *string `json:"group_name,omitempty"` // Название группы задачи
Rewards []RewardRequest `json:"rewards,omitempty"` Rewards []RewardRequest `json:"rewards,omitempty"`
Subtasks []SubtaskRequest `json:"subtasks,omitempty"` Subtasks []SubtaskRequest `json:"subtasks,omitempty"`
// Test-specific fields // Test-specific fields
@@ -465,8 +467,7 @@ type WishlistItem struct {
UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"` UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"`
LinkedTask *LinkedTask `json:"linked_task,omitempty"` LinkedTask *LinkedTask `json:"linked_task,omitempty"`
TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания
ProjectID *int `json:"project_id,omitempty"` // ID проекта, к которому принадлежит желание GroupName *string `json:"group_name,omitempty"` // Название группы желания
ProjectName *string `json:"project_name,omitempty"` // Название проекта
} }
type UnlockConditionDisplay struct { type UnlockConditionDisplay struct {
@@ -494,7 +495,7 @@ type WishlistRequest struct {
Name string `json:"name"` Name string `json:"name"`
Price *float64 `json:"price,omitempty"` Price *float64 `json:"price,omitempty"`
Link *string `json:"link,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"` 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}/uncomplete", app.uncompleteWishlistHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/{id}/copy", app.copyWishlistHandler).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 // Admin operations
protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS") 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 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Project renamed successfully", "message": "Project renamed successfully",
@@ -5949,8 +5958,10 @@ func (a *App) moveProjectHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// MV обновляется только по крону в понедельник в 6:00 утра // Обновляем MV для групповых саджестов (проект переименован или удалён)
// Данные текущей недели берутся напрямую из nodes 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.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
@@ -6027,8 +6038,10 @@ func (a *App) deleteProjectHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// MV обновляется только по крону в понедельник в 6:00 утра // Обновляем MV для групповых саджестов (проект удалён)
// Данные текущей недели берутся напрямую из nodes 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.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
@@ -6094,6 +6107,11 @@ func (a *App) createProjectHandler(w http.ResponseWriter, r *http.Request) {
return 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Project created successfully", "message": "Project created successfully",
@@ -7333,6 +7351,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
t.wishlist_id, t.wishlist_id,
t.config_id, t.config_id,
t.reward_policy, t.reward_policy,
t.group_name,
COALESCE(( COALESCE((
SELECT COUNT(*) SELECT COUNT(*)
FROM tasks st FROM tasks st
@@ -7389,6 +7408,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
var wishlistID sql.NullInt64 var wishlistID sql.NullInt64
var configID sql.NullInt64 var configID sql.NullInt64
var rewardPolicy sql.NullString var rewardPolicy sql.NullString
var groupName sql.NullString
var projectNames pq.StringArray var projectNames pq.StringArray
var subtaskProjectNames pq.StringArray var subtaskProjectNames pq.StringArray
var autoComplete bool var autoComplete bool
@@ -7405,6 +7425,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
&wishlistID, &wishlistID,
&configID, &configID,
&rewardPolicy, &rewardPolicy,
&groupName,
&task.SubtasksCount, &task.SubtasksCount,
&projectNames, &projectNames,
&subtaskProjectNames, &subtaskProjectNames,
@@ -7444,6 +7465,10 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
if rewardPolicy.Valid { if rewardPolicy.Valid {
task.RewardPolicy = &rewardPolicy.String task.RewardPolicy = &rewardPolicy.String
} }
if groupName.Valid && groupName.String != "" {
groupNameVal := groupName.String
task.GroupName = &groupNameVal
}
task.AutoComplete = autoComplete task.AutoComplete = autoComplete
// Объединяем проекты из основной задачи и подзадач // Объединяем проекты из основной задачи и подзадач
@@ -7504,6 +7529,7 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
var wishlistID sql.NullInt64 var wishlistID sql.NullInt64
var configID sql.NullInt64 var configID sql.NullInt64
var rewardPolicy sql.NullString var rewardPolicy sql.NullString
var groupName sql.NullString
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL // Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
var repetitionPeriodStr string var repetitionPeriodStr string
@@ -7514,11 +7540,12 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
COALESCE(repetition_date, '') as repetition_date, COALESCE(repetition_date, '') as repetition_date,
wishlist_id, wishlist_id,
config_id, config_id,
reward_policy reward_policy,
group_name
FROM tasks FROM tasks
WHERE id = $1 AND user_id = $2 AND deleted = FALSE WHERE id = $1 AND user_id = $2 AND deleted = FALSE
`, taskID, userID).Scan( `, 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) 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 { if rewardPolicy.Valid {
task.RewardPolicy = &rewardPolicy.String task.RewardPolicy = &rewardPolicy.String
} }
if groupName.Valid && groupName.String != "" {
groupNameVal := groupName.String
task.GroupName = &groupNameVal
}
// Получаем награды основной задачи // Получаем награды основной задачи
rewards := make([]Reward, 0) rewards := make([]Reward, 0)
@@ -8111,11 +8142,11 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
now := time.Now().In(loc) now := time.Now().In(loc)
insertSQL = ` insertSQL = `
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id, reward_policy) 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) VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, $6, 0, FALSE, $7, $8, $9)
RETURNING id 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 { } else if repetitionDate.Valid {
// Вычисляем next_show_at для задачи с repetition_date // Вычисляем 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)) nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now().In(loc))
if nextShowAt != nil { if nextShowAt != nil {
insertSQL = ` insertSQL = `
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id, reward_policy) 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) VALUES ($1, $2, $3, $4, NULL, $5, $6, 0, FALSE, $7, $8, $9)
RETURNING id RETURNING id
` `
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, wishlistIDValue, rewardPolicyValue} insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, wishlistIDValue, rewardPolicyValue, req.GroupName}
} else { } else {
insertSQL = ` insertSQL = `
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, wishlist_id, reward_policy) 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) VALUES ($1, $2, $3, $4, NULL, $5, 0, FALSE, $6, $7, $8)
RETURNING id RETURNING id
` `
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, wishlistIDValue, rewardPolicyValue} insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, wishlistIDValue, rewardPolicyValue, req.GroupName}
} }
} else { } else {
insertSQL = ` insertSQL = `
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, wishlist_id, reward_policy) 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) VALUES ($1, $2, $3, $4, NULL, NULL, 0, FALSE, $5, $6, $7)
RETURNING id 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) err = tx.QueryRow(insertSQL, insertArgs...).Scan(&taskID)
@@ -8316,6 +8347,13 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
return 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 createdTask Task
var lastCompletedAt sql.NullString var lastCompletedAt sql.NullString
@@ -8530,35 +8568,35 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
now := time.Now().In(loc) now := time.Now().In(loc)
updateSQL = ` updateSQL = `
UPDATE tasks 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 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 = $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 { } else if repetitionDate.Valid {
// Вычисляем next_show_at для задачи с repetition_date // Вычисляем next_show_at для задачи с repetition_date
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now().In(loc)) nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now().In(loc))
if nextShowAt != nil { if nextShowAt != nil {
updateSQL = ` updateSQL = `
UPDATE tasks 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 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 = $8 WHERE id = $9
` `
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, newWishlistID, rewardPolicyValue, taskID} updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, newWishlistID, rewardPolicyValue, req.GroupName, taskID}
} else { } else {
updateSQL = ` updateSQL = `
UPDATE tasks UPDATE tasks
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, wishlist_id = $5, reward_policy = $6 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 = $7 WHERE id = $8
` `
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, newWishlistID, rewardPolicyValue, taskID} updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, newWishlistID, rewardPolicyValue, req.GroupName, taskID}
} }
} else { } else {
updateSQL = ` updateSQL = `
UPDATE tasks 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 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 = $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...) _, err = tx.Exec(updateSQL, updateArgs...)
@@ -8877,6 +8915,13 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
return 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 updatedTask Task
var lastCompletedAt sql.NullString var lastCompletedAt sql.NullString
@@ -10347,8 +10392,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
wi.image_path, wi.image_path,
wi.link, wi.link,
wi.completed, wi.completed,
wi.project_id AS item_project_id, wi.group_name,
wp.name AS item_project_name,
wc.id AS condition_id, wc.id AS condition_id,
wc.display_order, wc.display_order,
wc.task_condition_id, wc.task_condition_id,
@@ -10361,7 +10405,6 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
sc.required_points, sc.required_points,
sc.start_date sc.start_date
FROM wishlist_items wi 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 wishlist_conditions wc ON wi.id = wc.wishlist_item_id
LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.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 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 price sql.NullFloat64
var imagePath, link sql.NullString var imagePath, link sql.NullString
var completed bool var completed bool
var itemProjectID sql.NullInt64 var groupName sql.NullString
var itemProjectName sql.NullString
var conditionID, displayOrder sql.NullInt64 var conditionID, displayOrder sql.NullInt64
var taskConditionID, scoreConditionID sql.NullInt64 var taskConditionID, scoreConditionID sql.NullInt64
@@ -10403,7 +10445,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
err := rows.Scan( err := rows.Scan(
&itemID, &name, &price, &imagePath, &link, &completed, &itemID, &name, &price, &imagePath, &link, &completed,
&itemProjectID, &itemProjectName, &groupName,
&conditionID, &displayOrder, &conditionID, &displayOrder,
&taskConditionID, &scoreConditionID, &conditionUserID, &taskConditionID, &scoreConditionID, &conditionUserID,
&taskID, &taskName, &taskID, &taskName,
@@ -10434,13 +10476,9 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
l := link.String l := link.String
item.Link = &l item.Link = &l
} }
if itemProjectID.Valid { if groupName.Valid && groupName.String != "" {
projectIDVal := int(itemProjectID.Int64) groupNameVal := groupName.String
item.ProjectID = &projectIDVal item.GroupName = &groupNameVal
}
if itemProjectName.Valid {
projectNameVal := itemProjectName.String
item.ProjectName = &projectNameVal
} }
itemsMap[itemID] = item itemsMap[itemID] = item
} }
@@ -11108,10 +11146,10 @@ func (a *App) createWishlistHandler(w http.ResponseWriter, r *http.Request) {
var wishlistID int var wishlistID int
err = tx.QueryRow(` 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) VALUES ($1, $1, $2, $3, $4, $5, FALSE, FALSE)
RETURNING id 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 { if err != nil {
log.Printf("Error creating wishlist item: %v", err) 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") 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) items, err := a.getWishlistItemsWithConditions(userID, false)
if err != nil { if err != nil {
@@ -11322,8 +11367,7 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
wi.image_path, wi.image_path,
wi.link, wi.link,
wi.completed, wi.completed,
wi.project_id AS item_project_id, wi.group_name,
wp.name AS item_project_name,
wc.id AS condition_id, wc.id AS condition_id,
wc.display_order, wc.display_order,
wc.task_condition_id, wc.task_condition_id,
@@ -11337,7 +11381,6 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
sc.required_points, sc.required_points,
sc.start_date sc.start_date
FROM wishlist_items wi 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 wishlist_conditions wc ON wi.id = wc.wishlist_item_id
LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.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 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 imagePath sql.NullString
var link sql.NullString var link sql.NullString
var completed bool var completed bool
var itemProjectID sql.NullInt64 var groupName sql.NullString
var itemProjectName sql.NullString
var conditionID sql.NullInt64 var conditionID sql.NullInt64
var displayOrder sql.NullInt64 var displayOrder sql.NullInt64
var taskConditionID sql.NullInt64 var taskConditionID sql.NullInt64
@@ -11380,7 +11422,7 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
var startDate sql.NullTime var startDate sql.NullTime
err := rows.Scan( err := rows.Scan(
&itemID, &name, &price, &imagePath, &link, &completed, &itemProjectID, &itemProjectName, &itemID, &name, &price, &imagePath, &link, &completed, &groupName,
&conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID,
&taskID, &taskName, &taskNextShowAt, &projectID, &projectName, &requiredPoints, &startDate, &taskID, &taskName, &taskNextShowAt, &projectID, &projectName, &requiredPoints, &startDate,
) )
@@ -11410,13 +11452,9 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
if link.Valid { if link.Valid {
item.Link = &link.String item.Link = &link.String
} }
if itemProjectID.Valid { if groupName.Valid && groupName.String != "" {
projectIDVal := int(itemProjectID.Int64) groupNameVal := groupName.String
item.ProjectID = &projectIDVal item.GroupName = &groupNameVal
}
if itemProjectName.Valid {
projectNameVal := itemProjectName.String
item.ProjectName = &projectNameVal
} }
itemsMap[itemID] = item itemsMap[itemID] = item
} }
@@ -11679,9 +11717,9 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) {
// Обновляем желание (не проверяем user_id в WHERE, так как доступ уже проверен выше) // Обновляем желание (не проверяем user_id в WHERE, так как доступ уже проверен выше)
_, err = tx.Exec(` _, err = tx.Exec(`
UPDATE wishlist_items 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 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 { if err != nil {
log.Printf("Error updating wishlist item: %v", err) log.Printf("Error updating wishlist item: %v", err)
@@ -11703,6 +11741,13 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) {
return 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 логику
// Используем тот же запрос, что и в getWishlistItemHandler // Используем тот же запрос, что и в getWishlistItemHandler
query := ` query := `
@@ -11713,6 +11758,7 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) {
wi.image_path, wi.image_path,
wi.link, wi.link,
wi.completed, wi.completed,
wi.group_name,
wc.id AS condition_id, wc.id AS condition_id,
wc.display_order, wc.display_order,
wc.task_condition_id, wc.task_condition_id,
@@ -11752,6 +11798,7 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) {
var imagePath sql.NullString var imagePath sql.NullString
var link sql.NullString var link sql.NullString
var completed bool var completed bool
var groupName sql.NullString
var conditionID sql.NullInt64 var conditionID sql.NullInt64
var displayOrder sql.NullInt64 var displayOrder sql.NullInt64
var taskConditionID sql.NullInt64 var taskConditionID sql.NullInt64
@@ -11765,7 +11812,7 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) {
var startDate sql.NullTime var startDate sql.NullTime
err := rows.Scan( err := rows.Scan(
&itemID, &name, &price, &imagePath, &link, &completed, &itemID, &name, &price, &imagePath, &link, &completed, &groupName,
&conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID,
&taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate, &taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate,
) )
@@ -11802,6 +11849,10 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) {
if link.Valid { if link.Valid {
item.Link = &link.String item.Link = &link.String
} }
if groupName.Valid && groupName.String != "" {
groupNameVal := groupName.String
item.GroupName = &groupNameVal
}
itemsMap[itemID] = item itemsMap[itemID] = item
} }
@@ -12416,11 +12467,12 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) {
var ownerID int var ownerID int
var boardID sql.NullInt64 var boardID sql.NullInt64
var authorID sql.NullInt64 var authorID sql.NullInt64
var groupName sql.NullString
err = a.DB.QueryRow(` 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 FROM wishlist_items
WHERE id = $1 AND deleted = FALSE 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 { if err == sql.ErrNoRows || ownerID != userID {
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) 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 // Определяем значения для board_id и author_id
var boardIDVal, authorIDVal interface{} var boardIDVal, authorIDVal, groupNameVal interface{}
if boardID.Valid { if boardID.Valid {
boardIDVal = int(boardID.Int64) boardIDVal = int(boardID.Int64)
} }
@@ -12522,12 +12574,15 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) {
// Если author_id не был установлен, используем текущего пользователя // Если author_id не был установлен, используем текущего пользователя
authorIDVal = userID authorIDVal = userID
} }
if groupName.Valid {
groupNameVal = groupName.String
}
err = tx.QueryRow(` err = tx.QueryRow(`
INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, 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, FALSE, FALSE) VALUES ($1, $2, $3, $4, $5, $6, $7, FALSE, FALSE)
RETURNING id RETURNING id
`, ownerID, boardIDVal, authorIDVal, name+" (копия)", priceVal, linkVal).Scan(&newWishlistID) `, ownerID, boardIDVal, authorIDVal, name+" (копия)", priceVal, linkVal, groupNameVal).Scan(&newWishlistID)
if err != nil { if err != nil {
log.Printf("Error creating wishlist copy: %v", err) log.Printf("Error creating wishlist copy: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating wishlist copy: %v", err), http.StatusInternalServerError) 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 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) items, err := a.getWishlistItemsWithConditions(userID, false)
if err != nil { if err != nil {
@@ -13844,8 +13906,7 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem,
wi.image_path, wi.image_path,
wi.link, wi.link,
wi.completed, wi.completed,
wi.project_id AS item_project_id, wi.group_name,
wp.name AS item_project_name,
COALESCE(wi.author_id, wi.user_id) AS item_owner_id, COALESCE(wi.author_id, wi.user_id) AS item_owner_id,
wc.id AS condition_id, wc.id AS condition_id,
wc.display_order, wc.display_order,
@@ -13859,7 +13920,6 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem,
sc.required_points, sc.required_points,
sc.start_date sc.start_date
FROM wishlist_items wi 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 wishlist_conditions wc ON wi.id = wc.wishlist_item_id
LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.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 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 imagePath sql.NullString
var link sql.NullString var link sql.NullString
var completed bool var completed bool
var itemProjectID sql.NullInt64 var groupName sql.NullString
var itemProjectName sql.NullString
var itemOwnerID sql.NullInt64 var itemOwnerID sql.NullInt64
var conditionID sql.NullInt64 var conditionID sql.NullInt64
var displayOrder sql.NullInt64 var displayOrder sql.NullInt64
@@ -13902,7 +13961,7 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem,
var startDate sql.NullTime var startDate sql.NullTime
err := rows.Scan( 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, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID,
&taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate, &taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate,
) )
@@ -13932,13 +13991,9 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem,
if link.Valid { if link.Valid {
item.Link = &link.String item.Link = &link.String
} }
if itemProjectID.Valid { if groupName.Valid && groupName.String != "" {
projectIDVal := int(itemProjectID.Int64) groupNameVal := groupName.String
item.ProjectID = &projectIDVal item.GroupName = &groupNameVal
}
if itemProjectName.Valid {
projectNameVal := itemProjectName.String
item.ProjectName = &projectNameVal
} }
itemsMap[itemID] = item itemsMap[itemID] = item
} }
@@ -14211,10 +14266,10 @@ func (a *App) createBoardItemHandler(w http.ResponseWriter, r *http.Request) {
var itemID int var itemID int
err = tx.QueryRow(` 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) VALUES ($1, $2, $3, $4, $5, $6, $7, FALSE, FALSE)
RETURNING id 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 { if err != nil {
log.Printf("createBoardItemHandler: Error creating board item: %v", err) log.Printf("createBoardItemHandler: Error creating board item: %v", err)
@@ -14244,6 +14299,13 @@ func (a *App) createBoardItemHandler(w http.ResponseWriter, r *http.Request) {
return 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
@@ -15521,3 +15583,57 @@ func decodeHTMLEntities(s string) string {
} }
return s 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)
}

View File

@@ -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;

View File

@@ -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';

View File

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

View File

@@ -17,6 +17,11 @@
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
flex: 1; flex: 1;
height: 44px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
} }
.submit-button:hover:not(:disabled) { .submit-button:hover:not(:disabled) {
@@ -32,7 +37,7 @@
.delete-button { .delete-button {
background: #ef4444; background: #ef4444;
color: white; color: white;
padding: 0.75rem; padding: 0;
border: none; border: none;
border-radius: 0.375rem; border-radius: 0.375rem;
font-size: 1rem; font-size: 1rem;
@@ -44,6 +49,8 @@
justify-content: center; justify-content: center;
min-width: 44px; min-width: 44px;
width: 44px; width: 44px;
height: 44px;
box-sizing: border-box;
} }
.delete-button:hover:not(:disabled) { .delete-button:hover:not(:disabled) {

View File

@@ -546,3 +546,65 @@
color: #6b7280; color: #6b7280;
font-style: italic; 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;
}

View File

@@ -19,6 +19,8 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
const [rewards, setRewards] = useState([]) const [rewards, setRewards] = useState([])
const [subtasks, setSubtasks] = useState([]) const [subtasks, setSubtasks] = useState([])
const [projects, setProjects] = useState([]) const [projects, setProjects] = useState([])
const [groupName, setGroupName] = useState('')
const [groupSuggestions, setGroupSuggestions] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') // Только для валидации const [error, setError] = useState('') // Только для валидации
const [toastMessage, setToastMessage] = useState(null) const [toastMessage, setToastMessage] = useState(null)
@@ -49,7 +51,23 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
} }
} }
loadProjects() 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(() => { useEffect(() => {
@@ -350,6 +368,13 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
} else { } else {
setRewardPolicy('personal') // Значение по умолчанию setRewardPolicy('personal') // Значение по умолчанию
} }
// Загружаем группу
if (data.task.group_name) {
setGroupName(data.task.group_name)
} else {
setGroupName('')
}
} else { } else {
setCurrentWishlistId(null) setCurrentWishlistId(null)
setWishlistInfo(null) setWishlistInfo(null)
@@ -684,6 +709,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
// Отправляем reward_policy если задача связана с желанием // Отправляем reward_policy если задача связана с желанием
// Проверяем currentWishlistId или wishlistInfo, так как currentWishlistId устанавливается при загрузке задачи // Проверяем currentWishlistId или wishlistInfo, так как currentWishlistId устанавливается при загрузке задачи
reward_policy: (wishlistInfo || currentWishlistId) ? rewardPolicy : undefined, reward_policy: (wishlistInfo || currentWishlistId) ? rewardPolicy : undefined,
group_name: groupName.trim() || null,
rewards: rewards.map(r => ({ rewards: rewards.map(r => ({
position: r.position, position: r.position,
project_name: r.project_name.trim(), project_name: r.project_name.trim(),
@@ -833,6 +859,15 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
/> />
</div> </div>
<div className="form-group">
<label htmlFor="group">Группа</label>
<GroupAutocomplete
suggestions={groupSuggestions}
value={groupName}
onChange={setGroupName}
/>
</div>
{/* Информация о связанном желании */} {/* Информация о связанном желании */}
{wishlistInfo && ( {wishlistInfo && (
<div className="form-group"> <div className="form-group">
@@ -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 (
<div className="group-autocomplete" ref={wrapperRef}>
<div className="group-autocomplete-input-wrapper">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="Введите название группы..."
className="form-input"
autoComplete="off"
/>
{inputValue && (
<button
type="button"
onClick={() => {
setInputValue('')
onChange('')
inputRef.current?.focus()
}}
className="group-autocomplete-clear"
>
</button>
)}
</div>
{isOpen && filteredSuggestions.length > 0 && (
<div className="group-autocomplete-dropdown">
{filteredSuggestions.map((group, index) => (
<div
key={group}
className={`group-autocomplete-item ${
highlightedIndex === index ? 'highlighted' : ''
}`}
onClick={() => handleSelectGroup(group)}
onMouseEnter={() => setHighlightedIndex(index)}
>
{group}
</div>
))}
</div>
)}
</div>
)
}
export default TaskForm export default TaskForm

View File

@@ -21,7 +21,7 @@
.task-search-input { .task-search-input {
width: 100%; width: 100%;
padding: 0.75rem 2.5rem 0.75rem 3rem; padding: 0.75rem 5rem 0.75rem 3rem;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 0.5rem; border-radius: 0.5rem;
font-size: 1rem; font-size: 1rem;
@@ -40,9 +40,34 @@
color: #9ca3af; 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 { .task-search-clear {
position: absolute; position: absolute;
right: 0.75rem; right: 0.75rem; /* Остаётся на месте */
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
background: none; background: none;

View File

@@ -23,6 +23,25 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
const [isPostponing, setIsPostponing] = useState(false) const [isPostponing, setIsPostponing] = useState(false)
const [toast, setToast] = useState(null) const [toast, setToast] = useState(null)
const [searchQuery, setSearchQuery] = useState('') 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(() => { useEffect(() => {
if (data) { if (data) {
@@ -508,6 +527,16 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
return [] 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) => { const isZeroPeriod = (intervalStr) => {
if (!intervalStr) return false if (!intervalStr) return false
@@ -548,7 +577,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
return !isNaN(numValue) && numValue === 0 return !isNaN(numValue) && numValue === 0
} }
// Группируем задачи по проектам // Группируем задачи по проектам или группам
const groupedTasks = useMemo(() => { const groupedTasks = useMemo(() => {
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0)
@@ -563,11 +592,18 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
const groups = {} const groups = {}
filteredTasks.forEach(task => { filteredTasks.forEach(task => {
const projects = getTaskProjects(task) let groupKeys = []
// Если у задачи нет проектов, добавляем в группу "Без проекта" if (groupingMode === 'project') {
if (projects.length === 0) { // Группировка по проекту (текущее поведение)
projects.push('Без проекта') 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 isCompleted = false
} }
projects.forEach(projectName => { groupKeys.forEach(groupKey => {
if (!groups[projectName]) { if (!groups[groupKey]) {
groups[projectName] = { groups[groupKey] = {
notCompleted: [], notCompleted: [],
completed: [] completed: []
} }
} }
if (isCompleted) { if (isCompleted) {
groups[projectName].completed.push(task) groups[groupKey].completed.push(task)
} else { } 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 return groups
}, [tasks, searchQuery]) }, [tasks, searchQuery, groupingMode])
// Сортируем проекты: сначала с невыполненными задачами, потом без них // Сортируем проекты: сначала с невыполненными задачами, потом без них
// Группа "Без проекта" всегда последняя в своей категории // Группа "Без проекта" всегда последняя в своей категории
@@ -667,12 +703,12 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
if (!hasNotCompletedA && hasNotCompletedB) return 1 if (!hasNotCompletedA && hasNotCompletedB) return 1
// Если обе группы в одной категории // Если обе группы в одной категории
const isNoProjectA = a === 'Без проекта' const isOthersA = a === 'Остальные'
const isNoProjectB = b === 'Без проекта' const isOthersB = b === 'Остальные'
// "Без проекта" всегда последняя в своей категории // "Остальные" всегда последняя в своей категории
if (isNoProjectA && !isNoProjectB) return 1 if (isOthersA && !isOthersB) return 1
if (!isNoProjectA && isNoProjectB) return -1 if (!isOthersA && isOthersB) return -1
// Остальные группы сортируем по алфавиту // Остальные группы сортируем по алфавиту
return a.localeCompare(b) return a.localeCompare(b)
@@ -953,6 +989,26 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
{/* Кнопка переключения группировки */}
<button
type="button"
className="task-grouping-toggle"
onClick={() => setGroupingMode(prev => prev === 'project' ? 'group' : 'project')}
title={groupingMode === 'project' ? 'Группировка по проекту' : 'Группировка по группе'}
>
{groupingMode === 'project' ? (
// Иконка "папка" для группировки по проекту
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
) : (
// Иконка "тег" для группировки по группе
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg>
)}
</button>
{searchQuery && ( {searchQuery && (
<button <button
className="task-search-clear" className="task-search-clear"

View File

@@ -577,59 +577,33 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
) )
} }
// Группируем желания по проектам // Группируем желания по группам
const groupedItems = useMemo(() => { const groupedItems = useMemo(() => {
const groups = {} const groups = {}
const noProjectItems = [] const noGroupItems = []
items.forEach(item => { items.forEach(item => {
if (item.project_id && item.project_name) { if (item.group_name && item.group_name.trim()) {
const projectId = item.project_id const groupName = item.group_name.trim()
if (!groups[projectId]) { if (!groups[groupName]) {
groups[projectId] = { groups[groupName] = {
projectId: projectId, groupName: groupName,
projectName: item.project_name,
items: [] items: []
} }
} }
groups[projectId].items.push(item) groups[groupName].items.push(item)
} else { } else {
noProjectItems.push(item) noGroupItems.push(item)
} }
}) })
// Сортируем группы проектов // Сортируем группы по алфавиту
const projectIds = Object.keys(groups)
if (currentWeekData && projectIds.length > 0) {
const projectNames = projectIds.map(id => groups[id].projectName)
const sortedProjectNames = sortProjectsLikeCurrentWeek(projectNames, currentWeekData)
// Создаем отсортированный массив групп
const sortedGroups = []
sortedProjectNames.forEach(projectName => {
const group = Object.values(groups).find(g => g.projectName === projectName)
if (group) {
sortedGroups.push(group)
}
})
// Добавляем группы, которых нет в currentWeekData (если есть)
Object.values(groups).forEach(group => {
if (!sortedProjectNames.includes(group.projectName)) {
sortedGroups.push(group)
}
})
return { groups: sortedGroups, noProjectItems }
}
// Если нет данных текущей недели, сортируем по алфавиту
const sortedGroups = Object.values(groups).sort((a, b) => const sortedGroups = Object.values(groups).sort((a, b) =>
a.projectName.localeCompare(b.projectName) a.groupName.localeCompare(b.groupName)
) )
return { groups: sortedGroups, noProjectItems } return { groups: sortedGroups, noGroupItems }
}, [items, currentWeekData]) }, [items])
const renderItem = (item) => { const renderItem = (item) => {
const isFaded = (!item.unlocked && !item.completed) || item.completed const isFaded = (!item.unlocked && !item.completed) || item.completed
@@ -722,19 +696,19 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
<> <>
{/* Группы проектов */} {/* Группы проектов */}
{groupedItems.groups.map(group => ( {groupedItems.groups.map(group => (
<div key={group.projectId} className="wishlist-project-group"> <div key={group.groupName} className="wishlist-project-group">
<div className="wishlist-project-group-title">{group.projectName}</div> <div className="wishlist-project-group-title">{group.groupName}</div>
<div className="wishlist-project-group-items"> <div className="wishlist-project-group-items">
{group.items.map(renderItem)} {group.items.map(renderItem)}
</div> </div>
</div> </div>
))} ))}
{/* Желания без проекта */} {/* Желания без группы */}
{groupedItems.noProjectItems.length > 0 && ( {groupedItems.noGroupItems.length > 0 && (
<div className="wishlist-no-project"> <div className="wishlist-no-project">
<div className="wishlist-grid"> <div className="wishlist-grid">
{groupedItems.noProjectItems.map(renderItem)} {groupedItems.noGroupItems.map(renderItem)}
</div> </div>
</div> </div>
)} )}

View File

@@ -650,3 +650,64 @@
background: #e0e7ff; background: #e0e7ff;
} }
/* 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;
}

View File

@@ -26,7 +26,8 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
const [editingConditionIndex, setEditingConditionIndex] = useState(null) const [editingConditionIndex, setEditingConditionIndex] = useState(null)
const [tasks, setTasks] = useState([]) const [tasks, setTasks] = useState([])
const [projects, setProjects] = useState([]) const [projects, setProjects] = useState([])
const [selectedProjectId, setSelectedProjectId] = useState('') const [groupName, setGroupName] = useState('')
const [groupSuggestions, setGroupSuggestions] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [toastMessage, setToastMessage] = useState(null) const [toastMessage, setToastMessage] = useState(null)
@@ -36,7 +37,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
const [loadedWishlistData, setLoadedWishlistData] = useState(null) // Данные желания для последующего маппинга условий const [loadedWishlistData, setLoadedWishlistData] = useState(null) // Данные желания для последующего маппинга условий
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
// Загрузка задач и проектов // Загрузка задач, проектов и саджестов групп
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
try { try {
@@ -47,18 +48,25 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
setTasks(Array.isArray(tasksData) ? tasksData : []) setTasks(Array.isArray(tasksData) ? tasksData : [])
} }
// Загружаем проекты // Загружаем проекты (нужны для ConditionForm)
const projectsResponse = await authFetch(PROJECTS_API_URL) const projectsResponse = await authFetch(PROJECTS_API_URL)
if (projectsResponse.ok) { if (projectsResponse.ok) {
const projectsData = await projectsResponse.json() const projectsData = await projectsResponse.json()
setProjects(Array.isArray(projectsData) ? projectsData : []) setProjects(Array.isArray(projectsData) ? projectsData : [])
} }
// Загружаем саджесты групп
const groupsResponse = await authFetch('/api/group-suggestions')
if (groupsResponse.ok) {
const groupsData = await groupsResponse.json()
setGroupSuggestions(Array.isArray(groupsData) ? groupsData : [])
}
} catch (err) { } catch (err) {
console.error('Error loading data:', err) console.error('Error loading data:', err)
} }
} }
loadData() loadData()
}, []) }, [authFetch])
// Загрузка желания при редактировании или сброс формы при создании // Загрузка желания при редактировании или сброс формы при создании
useEffect(() => { useEffect(() => {
@@ -88,7 +96,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
setPrice(data.price ? String(data.price) : '') setPrice(data.price ? String(data.price) : '')
setLink(data.link || '') setLink(data.link || '')
setImageUrl(data.image_url || null) setImageUrl(data.image_url || null)
setSelectedProjectId(data.project_id ? String(data.project_id) : '') setGroupName(data.group_name || '')
if (data.unlock_conditions) { if (data.unlock_conditions) {
setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({ setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({
id: cond.id || null, id: cond.id || null,
@@ -248,7 +256,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
setImageUrl(data.image_url || null) setImageUrl(data.image_url || null)
setImageFile(null) // Сбрасываем imageFile при загрузке существующего желания setImageFile(null) // Сбрасываем imageFile при загрузке существующего желания
setImageRemoved(false) // Сбрасываем флаг удаления при загрузке setImageRemoved(false) // Сбрасываем флаг удаления при загрузке
setSelectedProjectId(data.project_id ? String(data.project_id) : '') setGroupName(data.group_name || '')
if (data.unlock_conditions) { if (data.unlock_conditions) {
setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({ setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({
id: cond.id || null, id: cond.id || null,
@@ -276,7 +284,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
setImageUrl(data.image_url || null) setImageUrl(data.image_url || null)
setImageFile(null) setImageFile(null)
setImageRemoved(false) // Сбрасываем флаг удаления при загрузке setImageRemoved(false) // Сбрасываем флаг удаления при загрузке
setSelectedProjectId(data.project_id ? String(data.project_id) : '') setGroupName(data.group_name || '')
} }
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@@ -293,7 +301,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
setImageFile(null) setImageFile(null)
setImageRemoved(false) setImageRemoved(false)
setUnlockConditions([]) setUnlockConditions([])
setSelectedProjectId('') setGroupName('')
setError('') setError('')
setShowCropper(false) setShowCropper(false)
setCrop({ x: 0, y: 0 }) setCrop({ x: 0, y: 0 })
@@ -567,7 +575,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
name: name.trim(), name: name.trim(),
price: price ? parseFloat(price) : null, price: price ? parseFloat(price) : null,
link: link.trim() || null, link: link.trim() || null,
project_id: selectedProjectId ? parseInt(selectedProjectId, 10) : null, group_name: groupName.trim() || null,
unlock_conditions: unlockConditions.map(cond => ({ unlock_conditions: unlockConditions.map(cond => ({
id: cond.id || null, id: cond.id || null,
type: cond.type, type: cond.type,
@@ -743,6 +751,15 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
/> />
</div> </div>
<div className="form-group">
<label htmlFor="group">Группа</label>
<GroupAutocomplete
suggestions={groupSuggestions}
value={groupName}
onChange={setGroupName}
/>
</div>
<div className="form-group"> <div className="form-group">
<label>Картинка</label> <label>Картинка</label>
{imageUrl && !showCropper && ( {imageUrl && !showCropper && (
@@ -867,23 +884,6 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
</button> </button>
</div> </div>
<div className="form-group">
<label htmlFor="project">Принадлежность к проекту</label>
<select
id="project"
value={selectedProjectId}
onChange={(e) => setSelectedProjectId(e.target.value)}
className="form-input"
>
<option value="">Не выбран</option>
{projects.map(project => (
<option key={project.project_id} value={project.project_id}>
{project.project_name}
</option>
))}
</select>
</div>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
<div className="form-actions"> <div className="form-actions">
@@ -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 (
<div className="group-autocomplete" ref={wrapperRef}>
<div className="group-autocomplete-input-wrapper">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="Введите название группы..."
className="form-input"
autoComplete="off"
/>
{inputValue && (
<button
type="button"
onClick={() => {
setInputValue('')
onChange('')
inputRef.current?.focus()
}}
className="group-autocomplete-clear"
>
</button>
)}
</div>
{isOpen && filteredSuggestions.length > 0 && (
<div className="group-autocomplete-dropdown">
{filteredSuggestions.map((group, index) => (
<div
key={group}
className={`group-autocomplete-item ${
highlightedIndex === index ? 'highlighted' : ''
}`}
onClick={() => handleSelectGroup(group)}
onMouseEnter={() => setHighlightedIndex(index)}
>
{group}
</div>
))}
</div>
)}
</div>
)
}
// Компонент автодополнения для выбора задачи // Компонент автодополнения для выбора задачи
function TaskAutocomplete({ tasks, value, onChange, onCreateTask, preselectedTaskId }) { function TaskAutocomplete({ tasks, value, onChange, onCreateTask, preselectedTaskId }) {
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState('')