diff --git a/VERSION b/VERSION index b30c4dc..a69aa5a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.18.0 +4.19.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 7566813..c20eee9 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -284,6 +284,7 @@ type Task struct { WishlistID *int `json:"wishlist_id,omitempty"` ConfigID *int `json:"config_id,omitempty"` RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями + Position *int `json:"position,omitempty"` // Position for subtasks // Дополнительные поля для списка задач (без omitempty чтобы всегда передавались) ProjectNames []string `json:"project_names"` SubtasksCount int `json:"subtasks_count"` @@ -335,6 +336,7 @@ type SubtaskRequest struct { ID *int `json:"id,omitempty"` Name *string `json:"name,omitempty"` RewardMessage *string `json:"reward_message,omitempty"` + Position *int `json:"position,omitempty"` Rewards []RewardRequest `json:"rewards,omitempty"` } @@ -7370,10 +7372,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { // Получаем подзадачи subtasks := make([]Subtask, 0) subtaskRows, err := a.DB.Query(` - SELECT id, name, completed, last_completed_at, reward_message, progression_base + SELECT id, name, completed, last_completed_at, reward_message, progression_base, position FROM tasks WHERE parent_task_id = $1 AND deleted = FALSE - ORDER BY id + ORDER BY COALESCE(position, id) `, taskID) if err != nil { @@ -7388,10 +7390,12 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { var subtaskRewardMessage sql.NullString var subtaskProgressionBase sql.NullFloat64 var subtaskLastCompletedAt sql.NullString + var subtaskPosition sql.NullInt64 err := subtaskRows.Scan( &subtaskTask.ID, &subtaskTask.Name, &subtaskTask.Completed, &subtaskLastCompletedAt, &subtaskRewardMessage, &subtaskProgressionBase, + &subtaskPosition, ) if err != nil { log.Printf("Error scanning subtask: %v", err) @@ -7407,6 +7411,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { if subtaskLastCompletedAt.Valid { subtaskTask.LastCompletedAt = &subtaskLastCompletedAt.String } + if subtaskPosition.Valid { + pos := int(subtaskPosition.Int64) + subtaskTask.Position = &pos + } subtaskIDs = append(subtaskIDs, subtaskTask.ID) subtask := Subtask{ @@ -7944,10 +7952,11 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { } // Создаем подзадачи - for _, subtaskReq := range req.Subtasks { + for index, subtaskReq := range req.Subtasks { var subtaskName sql.NullString var subtaskRewardMessage sql.NullString var subtaskProgressionBase sql.NullFloat64 + var subtaskPosition sql.NullInt64 if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" { subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true} @@ -7958,13 +7967,19 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { if req.ProgressionBase != nil { subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} } + // Используем position из запроса, если указан, иначе используем индекс в массиве + if subtaskReq.Position != nil { + subtaskPosition = sql.NullInt64{Int64: int64(*subtaskReq.Position), Valid: true} + } else { + subtaskPosition = sql.NullInt64{Int64: int64(index), Valid: true} + } var subtaskID int err = tx.QueryRow(` - INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted) - VALUES ($1, $2, $3, $4, $5, 0, FALSE) + INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted, position) + VALUES ($1, $2, $3, $4, $5, 0, FALSE, $6) RETURNING id - `, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase).Scan(&subtaskID) + `, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase, subtaskPosition).Scan(&subtaskID) if err != nil { log.Printf("Error creating subtask: %v", err) @@ -8363,7 +8378,7 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) { // Обрабатываем подзадачи из запроса subtaskIDsInRequest := make(map[int]bool) - for _, subtaskReq := range req.Subtasks { + for index, subtaskReq := range req.Subtasks { if subtaskReq.ID != nil { subtaskIDsInRequest[*subtaskReq.ID] = true @@ -8371,6 +8386,7 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) { var subtaskName sql.NullString var subtaskRewardMessage sql.NullString var subtaskProgressionBase sql.NullFloat64 + var subtaskPosition sql.NullInt64 if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" { subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true} @@ -8381,12 +8397,18 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) { if req.ProgressionBase != nil { subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} } + // Используем position из запроса, если указан, иначе используем индекс в массиве + if subtaskReq.Position != nil { + subtaskPosition = sql.NullInt64{Int64: int64(*subtaskReq.Position), Valid: true} + } else { + subtaskPosition = sql.NullInt64{Int64: int64(index), Valid: true} + } _, err = tx.Exec(` UPDATE tasks - SET name = $1, reward_message = $2, progression_base = $3 - WHERE id = $4 AND parent_task_id = $5 - `, subtaskName, subtaskRewardMessage, subtaskProgressionBase, *subtaskReq.ID, taskID) + SET name = $1, reward_message = $2, progression_base = $3, position = $4 + WHERE id = $5 AND parent_task_id = $6 + `, subtaskName, subtaskRewardMessage, subtaskProgressionBase, subtaskPosition, *subtaskReq.ID, taskID) if err != nil { log.Printf("Error updating subtask: %v", err) @@ -8432,6 +8454,7 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) { var subtaskName sql.NullString var subtaskRewardMessage sql.NullString var subtaskProgressionBase sql.NullFloat64 + var subtaskPosition sql.NullInt64 if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" { subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true} @@ -8442,13 +8465,19 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) { if req.ProgressionBase != nil { subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} } + // Используем position из запроса, если указан, иначе используем индекс в массиве + if subtaskReq.Position != nil { + subtaskPosition = sql.NullInt64{Int64: int64(*subtaskReq.Position), Valid: true} + } else { + subtaskPosition = sql.NullInt64{Int64: int64(index), Valid: true} + } var subtaskID int err = tx.QueryRow(` - INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted) - VALUES ($1, $2, $3, $4, $5, 0, FALSE) + INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted, position) + VALUES ($1, $2, $3, $4, $5, 0, FALSE, $6) RETURNING id - `, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase).Scan(&subtaskID) + `, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase, subtaskPosition).Scan(&subtaskID) if err != nil { log.Printf("Error creating subtask: %v", err) diff --git a/play-life-backend/migrations/000012_add_subtask_position.down.sql b/play-life-backend/migrations/000012_add_subtask_position.down.sql new file mode 100644 index 0000000..751fc6d --- /dev/null +++ b/play-life-backend/migrations/000012_add_subtask_position.down.sql @@ -0,0 +1,9 @@ +-- Migration: Remove position field from tasks table +-- Date: 2026-02-02 +-- +-- This migration removes the position field from tasks table. + +DROP INDEX IF EXISTS idx_tasks_parent_position; + +ALTER TABLE tasks +DROP COLUMN IF EXISTS position; diff --git a/play-life-backend/migrations/000012_add_subtask_position.up.sql b/play-life-backend/migrations/000012_add_subtask_position.up.sql new file mode 100644 index 0000000..6617ff8 --- /dev/null +++ b/play-life-backend/migrations/000012_add_subtask_position.up.sql @@ -0,0 +1,49 @@ +-- Migration: Add position field to tasks table for subtasks ordering +-- Date: 2026-02-02 +-- +-- This migration adds position field to tasks table to allow +-- custom ordering of subtasks. The field is NULL for regular tasks +-- and contains position number for subtasks (tasks with parent_task_id). + +-- Добавляем поле position +ALTER TABLE tasks +ADD COLUMN position INTEGER; + +-- Заполняем позиции для всех существующих подзадач +-- Позиции присваиваются по порядку id в рамках каждой родительской задачи +DO $$ +DECLARE + parent_record RECORD; + subtask_record RECORD; + pos INTEGER; +BEGIN + -- Для каждой родительской задачи + FOR parent_record IN + SELECT DISTINCT parent_task_id + FROM tasks + WHERE parent_task_id IS NOT NULL + ORDER BY parent_task_id + LOOP + pos := 0; + -- Обновляем подзадачи этой родительской задачи + FOR subtask_record IN + SELECT id + FROM tasks + WHERE parent_task_id = parent_record.parent_task_id + AND deleted = FALSE + ORDER BY id + LOOP + UPDATE tasks + SET position = pos + WHERE id = subtask_record.id; + + pos := pos + 1; + END LOOP; + END LOOP; +END $$; + +-- Создаем индекс для быстрой сортировки подзадач +CREATE INDEX idx_tasks_parent_position ON tasks(parent_task_id, position) +WHERE parent_task_id IS NOT NULL AND deleted = FALSE; + +COMMENT ON COLUMN tasks.position IS 'Position of subtask within parent task. NULL for regular tasks.'; diff --git a/play-life-web/package.json b/play-life-web/package.json index 7c9afee..7382c10 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "4.18.0", + "version": "4.19.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/TaskForm.css b/play-life-web/src/components/TaskForm.css index 9106cde..0736567 100644 --- a/play-life-web/src/components/TaskForm.css +++ b/play-life-web/src/components/TaskForm.css @@ -261,6 +261,46 @@ margin-bottom: 0.75rem; } +.subtask-position-controls { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex-shrink: 0; +} + +.move-subtask-button { + padding: 0.25rem; + background: #f3f4f6; + color: #6b7280; + border: 1px solid #d1d5db; + border-radius: 0.25rem; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 1.75rem; + flex-shrink: 0; +} + +.move-subtask-button:hover:not(:disabled) { + background: #e5e7eb; + border-color: #9ca3af; + color: #374151; +} + +.move-subtask-button:disabled { + opacity: 0.4; + cursor: not-allowed; + background: #f9fafb; +} + +.move-subtask-button:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); +} + .subtask-name-input { flex: 1; margin-bottom: 0; diff --git a/play-life-web/src/components/TaskForm.jsx b/play-life-web/src/components/TaskForm.jsx index 15e3056..5c070a6 100644 --- a/play-life-web/src/components/TaskForm.jsx +++ b/play-life-web/src/components/TaskForm.jsx @@ -313,10 +313,11 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa // Для задач-тестов не загружаем подзадачи setSubtasks([]) } else { - setSubtasks(data.subtasks.map(st => ({ + setSubtasks(data.subtasks.map((st, index) => ({ id: st.task.id, name: st.task.name || '', reward_message: st.task.reward_message || '', + position: st.task.position !== undefined && st.task.position !== null ? st.task.position : index, rewards: st.rewards.map(r => ({ position: r.position, project_name: r.project_name, @@ -483,6 +484,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa id: null, name: '', reward_message: '', + position: subtasks.length, rewards: [] }]) } @@ -520,7 +522,38 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa } const handleRemoveSubtask = (index) => { - setSubtasks(subtasks.filter((_, i) => i !== index)) + const newSubtasks = subtasks.filter((_, i) => i !== index) + // Пересчитываем позиции после удаления + newSubtasks.forEach((st, i) => { + st.position = i + }) + setSubtasks(newSubtasks) + } + + const handleMoveSubtaskUp = (index) => { + if (index === 0) return // Нельзя переместить первый элемент вверх + const newSubtasks = [...subtasks] + const temp = newSubtasks[index] + newSubtasks[index] = newSubtasks[index - 1] + newSubtasks[index - 1] = temp + // Обновляем позиции + newSubtasks.forEach((st, i) => { + st.position = i + }) + setSubtasks(newSubtasks) + } + + const handleMoveSubtaskDown = (index) => { + if (index === subtasks.length - 1) return // Нельзя переместить последний элемент вниз + const newSubtasks = [...subtasks] + const temp = newSubtasks[index] + newSubtasks[index] = newSubtasks[index + 1] + newSubtasks[index + 1] = temp + // Обновляем позиции + newSubtasks.forEach((st, i) => { + st.position = i + }) + setSubtasks(newSubtasks) } const handleSubmit = async (e) => { @@ -657,10 +690,11 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa value: parseFloat(r.value) || 0, use_progression: !!(progressionBase && r.use_progression) })), - subtasks: isTest ? [] : subtasks.map(st => ({ + subtasks: isTest ? [] : subtasks.map((st, index) => ({ id: st.id || undefined, name: st.name.trim() || null, reward_message: st.reward_message.trim() || null, + position: st.position !== undefined && st.position !== null ? st.position : index, rewards: st.rewards.map(r => ({ position: r.position, project_name: r.project_name.trim(), @@ -1073,6 +1107,30 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa {subtasks.map((subtask, index) => (