4.25.0: Группы вместо проектов для задач и желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m31s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m31s
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
36
play-life-backend/migrations/000014_add_group_name.down.sql
Normal file
36
play-life-backend/migrations/000014_add_group_name.down.sql
Normal 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;
|
||||
60
play-life-backend/migrations/000014_add_group_name.up.sql
Normal file
60
play-life-backend/migrations/000014_add_group_name.up.sql
Normal 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';
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "4.24.7",
|
||||
"version": "4.25.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="group">Группа</label>
|
||||
<GroupAutocomplete
|
||||
suggestions={groupSuggestions}
|
||||
value={groupName}
|
||||
onChange={setGroupName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Информация о связанном желании */}
|
||||
{wishlistInfo && (
|
||||
<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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
{/* Кнопка переключения группировки */}
|
||||
<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 && (
|
||||
<button
|
||||
className="task-search-clear"
|
||||
|
||||
@@ -577,59 +577,33 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
||||
)
|
||||
}
|
||||
|
||||
// Группируем желания по проектам
|
||||
// Группируем желания по группам
|
||||
const groupedItems = useMemo(() => {
|
||||
const groups = {}
|
||||
const noProjectItems = []
|
||||
const noGroupItems = []
|
||||
|
||||
items.forEach(item => {
|
||||
if (item.project_id && item.project_name) {
|
||||
const projectId = item.project_id
|
||||
if (!groups[projectId]) {
|
||||
groups[projectId] = {
|
||||
projectId: projectId,
|
||||
projectName: item.project_name,
|
||||
if (item.group_name && item.group_name.trim()) {
|
||||
const groupName = item.group_name.trim()
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = {
|
||||
groupName: groupName,
|
||||
items: []
|
||||
}
|
||||
}
|
||||
groups[projectId].items.push(item)
|
||||
groups[groupName].items.push(item)
|
||||
} 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) =>
|
||||
a.projectName.localeCompare(b.projectName)
|
||||
a.groupName.localeCompare(b.groupName)
|
||||
)
|
||||
|
||||
return { groups: sortedGroups, noProjectItems }
|
||||
}, [items, currentWeekData])
|
||||
return { groups: sortedGroups, noGroupItems }
|
||||
}, [items])
|
||||
|
||||
const renderItem = (item) => {
|
||||
const isFaded = (!item.unlocked && !item.completed) || item.completed
|
||||
@@ -722,19 +696,19 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa
|
||||
<>
|
||||
{/* Группы проектов */}
|
||||
{groupedItems.groups.map(group => (
|
||||
<div key={group.projectId} className="wishlist-project-group">
|
||||
<div className="wishlist-project-group-title">{group.projectName}</div>
|
||||
<div key={group.groupName} className="wishlist-project-group">
|
||||
<div className="wishlist-project-group-title">{group.groupName}</div>
|
||||
<div className="wishlist-project-group-items">
|
||||
{group.items.map(renderItem)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Желания без проекта */}
|
||||
{groupedItems.noProjectItems.length > 0 && (
|
||||
{/* Желания без группы */}
|
||||
{groupedItems.noGroupItems.length > 0 && (
|
||||
<div className="wishlist-no-project">
|
||||
<div className="wishlist-grid">
|
||||
{groupedItems.noProjectItems.map(renderItem)}
|
||||
{groupedItems.noGroupItems.map(renderItem)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -650,3 +650,64 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
||||
const [editingConditionIndex, setEditingConditionIndex] = useState(null)
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [projects, setProjects] = useState([])
|
||||
const [selectedProjectId, setSelectedProjectId] = useState('')
|
||||
const [groupName, setGroupName] = useState('')
|
||||
const [groupSuggestions, setGroupSuggestions] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [toastMessage, setToastMessage] = useState(null)
|
||||
@@ -36,7 +37,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
||||
const [loadedWishlistData, setLoadedWishlistData] = useState(null) // Данные желания для последующего маппинга условий
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
// Загрузка задач и проектов
|
||||
// Загрузка задач, проектов и саджестов групп
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
@@ -47,18 +48,25 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
||||
setTasks(Array.isArray(tasksData) ? tasksData : [])
|
||||
}
|
||||
|
||||
// Загружаем проекты
|
||||
// Загружаем проекты (нужны для ConditionForm)
|
||||
const projectsResponse = await authFetch(PROJECTS_API_URL)
|
||||
if (projectsResponse.ok) {
|
||||
const projectsData = await projectsResponse.json()
|
||||
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) {
|
||||
console.error('Error loading data:', err)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [])
|
||||
}, [authFetch])
|
||||
|
||||
// Загрузка желания при редактировании или сброс формы при создании
|
||||
useEffect(() => {
|
||||
@@ -88,7 +96,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
||||
setPrice(data.price ? String(data.price) : '')
|
||||
setLink(data.link || '')
|
||||
setImageUrl(data.image_url || null)
|
||||
setSelectedProjectId(data.project_id ? String(data.project_id) : '')
|
||||
setGroupName(data.group_name || '')
|
||||
if (data.unlock_conditions) {
|
||||
setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({
|
||||
id: cond.id || null,
|
||||
@@ -246,9 +254,9 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
||||
setPrice(data.price ? String(data.price) : '')
|
||||
setLink(data.link || '')
|
||||
setImageUrl(data.image_url || null)
|
||||
setImageFile(null) // Сбрасываем imageFile при загрузке существующего желания
|
||||
setImageRemoved(false) // Сбрасываем флаг удаления при загрузке
|
||||
setSelectedProjectId(data.project_id ? String(data.project_id) : '')
|
||||
setImageFile(null) // Сбрасываем imageFile при загрузке существующего желания
|
||||
setImageRemoved(false) // Сбрасываем флаг удаления при загрузке
|
||||
setGroupName(data.group_name || '')
|
||||
if (data.unlock_conditions) {
|
||||
setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({
|
||||
id: cond.id || null,
|
||||
@@ -276,7 +284,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
||||
setImageUrl(data.image_url || null)
|
||||
setImageFile(null)
|
||||
setImageRemoved(false) // Сбрасываем флаг удаления при загрузке
|
||||
setSelectedProjectId(data.project_id ? String(data.project_id) : '')
|
||||
setGroupName(data.group_name || '')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
@@ -293,7 +301,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
||||
setImageFile(null)
|
||||
setImageRemoved(false)
|
||||
setUnlockConditions([])
|
||||
setSelectedProjectId('')
|
||||
setGroupName('')
|
||||
setError('')
|
||||
setShowCropper(false)
|
||||
setCrop({ x: 0, y: 0 })
|
||||
@@ -567,7 +575,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
||||
name: name.trim(),
|
||||
price: price ? parseFloat(price) : null,
|
||||
link: link.trim() || null,
|
||||
project_id: selectedProjectId ? parseInt(selectedProjectId, 10) : null,
|
||||
group_name: groupName.trim() || null,
|
||||
unlock_conditions: unlockConditions.map(cond => ({
|
||||
id: cond.id || null,
|
||||
type: cond.type,
|
||||
@@ -743,6 +751,15 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="group">Группа</label>
|
||||
<GroupAutocomplete
|
||||
suggestions={groupSuggestions}
|
||||
value={groupName}
|
||||
onChange={setGroupName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Картинка</label>
|
||||
{imageUrl && !showCropper && (
|
||||
@@ -867,23 +884,6 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
|
||||
</button>
|
||||
</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>}
|
||||
|
||||
<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 }) {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
Reference in New Issue
Block a user