4.19.0: Добавлены позиции подзадач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m34s

This commit is contained in:
poignatov
2026-02-04 21:21:07 +03:00
parent 09ab87b6dd
commit a60bfe97dc
7 changed files with 203 additions and 18 deletions

View File

@@ -1 +1 @@
4.18.0 4.19.0

View File

@@ -284,6 +284,7 @@ type Task struct {
WishlistID *int `json:"wishlist_id,omitempty"` WishlistID *int `json:"wishlist_id,omitempty"`
ConfigID *int `json:"config_id,omitempty"` ConfigID *int `json:"config_id,omitempty"`
RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями
Position *int `json:"position,omitempty"` // Position for subtasks
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались) // Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
ProjectNames []string `json:"project_names"` ProjectNames []string `json:"project_names"`
SubtasksCount int `json:"subtasks_count"` SubtasksCount int `json:"subtasks_count"`
@@ -335,6 +336,7 @@ type SubtaskRequest struct {
ID *int `json:"id,omitempty"` ID *int `json:"id,omitempty"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
RewardMessage *string `json:"reward_message,omitempty"` RewardMessage *string `json:"reward_message,omitempty"`
Position *int `json:"position,omitempty"`
Rewards []RewardRequest `json:"rewards,omitempty"` Rewards []RewardRequest `json:"rewards,omitempty"`
} }
@@ -7370,10 +7372,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
// Получаем подзадачи // Получаем подзадачи
subtasks := make([]Subtask, 0) subtasks := make([]Subtask, 0)
subtaskRows, err := a.DB.Query(` 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 FROM tasks
WHERE parent_task_id = $1 AND deleted = FALSE WHERE parent_task_id = $1 AND deleted = FALSE
ORDER BY id ORDER BY COALESCE(position, id)
`, taskID) `, taskID)
if err != nil { if err != nil {
@@ -7388,10 +7390,12 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
var subtaskRewardMessage sql.NullString var subtaskRewardMessage sql.NullString
var subtaskProgressionBase sql.NullFloat64 var subtaskProgressionBase sql.NullFloat64
var subtaskLastCompletedAt sql.NullString var subtaskLastCompletedAt sql.NullString
var subtaskPosition sql.NullInt64
err := subtaskRows.Scan( err := subtaskRows.Scan(
&subtaskTask.ID, &subtaskTask.Name, &subtaskTask.Completed, &subtaskTask.ID, &subtaskTask.Name, &subtaskTask.Completed,
&subtaskLastCompletedAt, &subtaskRewardMessage, &subtaskProgressionBase, &subtaskLastCompletedAt, &subtaskRewardMessage, &subtaskProgressionBase,
&subtaskPosition,
) )
if err != nil { if err != nil {
log.Printf("Error scanning subtask: %v", err) log.Printf("Error scanning subtask: %v", err)
@@ -7407,6 +7411,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
if subtaskLastCompletedAt.Valid { if subtaskLastCompletedAt.Valid {
subtaskTask.LastCompletedAt = &subtaskLastCompletedAt.String subtaskTask.LastCompletedAt = &subtaskLastCompletedAt.String
} }
if subtaskPosition.Valid {
pos := int(subtaskPosition.Int64)
subtaskTask.Position = &pos
}
subtaskIDs = append(subtaskIDs, subtaskTask.ID) subtaskIDs = append(subtaskIDs, subtaskTask.ID)
subtask := Subtask{ 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 subtaskName sql.NullString
var subtaskRewardMessage sql.NullString var subtaskRewardMessage sql.NullString
var subtaskProgressionBase sql.NullFloat64 var subtaskProgressionBase sql.NullFloat64
var subtaskPosition sql.NullInt64
if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" { if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" {
subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true} 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 { if req.ProgressionBase != nil {
subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} 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 var subtaskID int
err = tx.QueryRow(` err = tx.QueryRow(`
INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted) INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted, position)
VALUES ($1, $2, $3, $4, $5, 0, FALSE) VALUES ($1, $2, $3, $4, $5, 0, FALSE, $6)
RETURNING id RETURNING id
`, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase).Scan(&subtaskID) `, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase, subtaskPosition).Scan(&subtaskID)
if err != nil { if err != nil {
log.Printf("Error creating subtask: %v", err) 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) subtaskIDsInRequest := make(map[int]bool)
for _, subtaskReq := range req.Subtasks { for index, subtaskReq := range req.Subtasks {
if subtaskReq.ID != nil { if subtaskReq.ID != nil {
subtaskIDsInRequest[*subtaskReq.ID] = true subtaskIDsInRequest[*subtaskReq.ID] = true
@@ -8371,6 +8386,7 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
var subtaskName sql.NullString var subtaskName sql.NullString
var subtaskRewardMessage sql.NullString var subtaskRewardMessage sql.NullString
var subtaskProgressionBase sql.NullFloat64 var subtaskProgressionBase sql.NullFloat64
var subtaskPosition sql.NullInt64
if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" { if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" {
subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true} 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 { if req.ProgressionBase != nil {
subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} 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(` _, err = tx.Exec(`
UPDATE tasks UPDATE tasks
SET name = $1, reward_message = $2, progression_base = $3 SET name = $1, reward_message = $2, progression_base = $3, position = $4
WHERE id = $4 AND parent_task_id = $5 WHERE id = $5 AND parent_task_id = $6
`, subtaskName, subtaskRewardMessage, subtaskProgressionBase, *subtaskReq.ID, taskID) `, subtaskName, subtaskRewardMessage, subtaskProgressionBase, subtaskPosition, *subtaskReq.ID, taskID)
if err != nil { if err != nil {
log.Printf("Error updating subtask: %v", err) 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 subtaskName sql.NullString
var subtaskRewardMessage sql.NullString var subtaskRewardMessage sql.NullString
var subtaskProgressionBase sql.NullFloat64 var subtaskProgressionBase sql.NullFloat64
var subtaskPosition sql.NullInt64
if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" { if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" {
subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true} 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 { if req.ProgressionBase != nil {
subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} 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 var subtaskID int
err = tx.QueryRow(` err = tx.QueryRow(`
INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted) INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted, position)
VALUES ($1, $2, $3, $4, $5, 0, FALSE) VALUES ($1, $2, $3, $4, $5, 0, FALSE, $6)
RETURNING id RETURNING id
`, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase).Scan(&subtaskID) `, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase, subtaskPosition).Scan(&subtaskID)
if err != nil { if err != nil {
log.Printf("Error creating subtask: %v", err) log.Printf("Error creating subtask: %v", err)

View File

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

View File

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

View File

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

View File

@@ -261,6 +261,46 @@
margin-bottom: 0.75rem; 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 { .subtask-name-input {
flex: 1; flex: 1;
margin-bottom: 0; margin-bottom: 0;

View File

@@ -313,10 +313,11 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
// Для задач-тестов не загружаем подзадачи // Для задач-тестов не загружаем подзадачи
setSubtasks([]) setSubtasks([])
} else { } else {
setSubtasks(data.subtasks.map(st => ({ setSubtasks(data.subtasks.map((st, index) => ({
id: st.task.id, id: st.task.id,
name: st.task.name || '', name: st.task.name || '',
reward_message: st.task.reward_message || '', reward_message: st.task.reward_message || '',
position: st.task.position !== undefined && st.task.position !== null ? st.task.position : index,
rewards: st.rewards.map(r => ({ rewards: st.rewards.map(r => ({
position: r.position, position: r.position,
project_name: r.project_name, project_name: r.project_name,
@@ -483,6 +484,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
id: null, id: null,
name: '', name: '',
reward_message: '', reward_message: '',
position: subtasks.length,
rewards: [] rewards: []
}]) }])
} }
@@ -520,7 +522,38 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
} }
const handleRemoveSubtask = (index) => { 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) => { const handleSubmit = async (e) => {
@@ -657,10 +690,11 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
value: parseFloat(r.value) || 0, value: parseFloat(r.value) || 0,
use_progression: !!(progressionBase && r.use_progression) use_progression: !!(progressionBase && r.use_progression)
})), })),
subtasks: isTest ? [] : subtasks.map(st => ({ subtasks: isTest ? [] : subtasks.map((st, index) => ({
id: st.id || undefined, id: st.id || undefined,
name: st.name.trim() || null, name: st.name.trim() || null,
reward_message: st.reward_message.trim() || null, reward_message: st.reward_message.trim() || null,
position: st.position !== undefined && st.position !== null ? st.position : index,
rewards: st.rewards.map(r => ({ rewards: st.rewards.map(r => ({
position: r.position, position: r.position,
project_name: r.project_name.trim(), project_name: r.project_name.trim(),
@@ -1073,6 +1107,30 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
{subtasks.map((subtask, index) => ( {subtasks.map((subtask, index) => (
<div key={index} className="subtask-form-item"> <div key={index} className="subtask-form-item">
<div className="subtask-header-row"> <div className="subtask-header-row">
<div className="subtask-position-controls">
<button
type="button"
onClick={() => handleMoveSubtaskUp(index)}
className="move-subtask-button"
disabled={index === 0}
title="Переместить вверх"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>
<button
type="button"
onClick={() => handleMoveSubtaskDown(index)}
className="move-subtask-button"
disabled={index === subtasks.length - 1}
title="Переместить вниз"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
</div>
<input <input
type="text" type="text"
value={subtask.name} value={subtask.name}