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
// Дополнительные поля для списка задач (без 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)
}

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",
"version": "4.24.7",
"version": "4.25.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
)}

View File

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

View File

@@ -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('')