diff --git a/.cursor/rules/restart_on_changes.mdc b/.cursor/rules/restart_on_changes.mdc new file mode 100644 index 0000000..6de4137 --- /dev/null +++ b/.cursor/rules/restart_on_changes.mdc @@ -0,0 +1,16 @@ +--- +description: Перезапуск приложения после изменений в бэкенде или фронтенде +alwaysApply: true +--- + +## Правило перезапуска приложения + +**ВАЖНО:** После применения всех изменений в бэкенде (`play-life-backend/`) или фронтенде (`play-life-web/`), а также после изменений в `docker-compose.yml`, **ОБЯЗАТЕЛЬНО** выполни команду `./run.sh` для перезапуска всех сервисов приложения. + +Это правило применяется при работе с: +- Go кодом в `play-life-backend/` +- Миграциями базы данных в `play-life-backend/migrations/` +- React компонентами и стилями в `play-life-web/src/` +- Docker конфигурациями (`docker-compose.yml`, `Dockerfile`) + +**Команда для перезапуска:** `./run.sh` или `bash run.sh` в корне проекта. diff --git a/.vscode/launch.json b/.vscode/launch.json index 222a929..e913111 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,28 +1,4 @@ { "version": "0.2.0", - "configurations": [ - { - "name": "Restart Server", - "type": "node", - "request": "launch", - "runtimeExecutable": "bash", - "runtimeArgs": ["${workspaceFolder}/run.sh"], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "skipFiles": ["/**"] - }, - { - "name": "Init Server", - "type": "node", - "request": "launch", - "runtimeExecutable": "bash", - "runtimeArgs": ["${workspaceFolder}/init.sh"], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "skipFiles": ["/**"] - } - ] + "configurations": [] } - diff --git a/VERSION b/VERSION index c8e38b6..dedcc7d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.9.0 +2.9.1 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 10cfc68..0434b3b 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -198,6 +198,64 @@ type TelegramUpdate struct { EditedMessage *TelegramMessage `json:"edited_message,omitempty"` } +// Task structures +type Task struct { + ID int `json:"id"` + Name string `json:"name"` + Completed int `json:"completed"` + LastCompletedAt *string `json:"last_completed_at,omitempty"` + RewardMessage *string `json:"reward_message,omitempty"` + ProgressionBase *float64 `json:"progression_base,omitempty"` + RepetitionPeriod *string `json:"repetition_period,omitempty"` +} + +type Reward struct { + ID int `json:"id"` + Position int `json:"position"` + ProjectName string `json:"project_name"` + Value float64 `json:"value"` + UseProgression bool `json:"use_progression"` +} + +type Subtask struct { + Task Task `json:"task"` + Rewards []Reward `json:"rewards"` +} + +type TaskDetail struct { + Task Task `json:"task"` + Rewards []Reward `json:"rewards"` + Subtasks []Subtask `json:"subtasks"` +} + +type RewardRequest struct { + Position int `json:"position"` + ProjectName string `json:"project_name"` + Value float64 `json:"value"` + UseProgression bool `json:"use_progression"` +} + +type SubtaskRequest struct { + ID *int `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + RewardMessage *string `json:"reward_message,omitempty"` + Rewards []RewardRequest `json:"rewards,omitempty"` +} + +type TaskRequest struct { + Name string `json:"name"` + ProgressionBase *float64 `json:"progression_base,omitempty"` + RewardMessage *string `json:"reward_message,omitempty"` + RepetitionPeriod *string `json:"repetition_period,omitempty"` + Rewards []RewardRequest `json:"rewards,omitempty"` + Subtasks []SubtaskRequest `json:"subtasks,omitempty"` +} + +type CompleteTaskRequest struct { + Value *float64 `json:"value,omitempty"` + ChildrenTaskIDs []int `json:"children_task_ids,omitempty"` +} + // ============================================ // Auth types // ============================================ @@ -2491,7 +2549,7 @@ func (a *App) initAuthDB() error { a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash)") // Add user_id column to all tables if not exists - tables := []string{"projects", "entries", "nodes", "dictionaries", "words", "progress", "configs", "telegram_integrations", "weekly_goals"} + tables := []string{"projects", "entries", "nodes", "dictionaries", "words", "progress", "configs", "telegram_integrations", "weekly_goals", "tasks"} for _, table := range tables { alterSQL := fmt.Sprintf("ALTER TABLE %s ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE", table) if _, err := a.DB.Exec(alterSQL); err != nil { @@ -2817,6 +2875,69 @@ func (a *App) initPlayLifeDB() error { return fmt.Errorf("failed to create telegram_integrations table: %w", err) } + // Создаем таблицу tasks + createTasksTable := ` + CREATE TABLE IF NOT EXISTS tasks ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + completed INTEGER DEFAULT 0, + last_completed_at TIMESTAMP WITH TIME ZONE, + parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE, + reward_message TEXT, + progression_base NUMERIC(10,4), + deleted BOOLEAN DEFAULT FALSE + ) + ` + if _, err := a.DB.Exec(createTasksTable); err != nil { + return fmt.Errorf("failed to create tasks table: %w", err) + } + + // Создаем индексы для tasks + createTasksIndexes := []string{ + `CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_tasks_parent_task_id ON tasks(parent_task_id)`, + `CREATE INDEX IF NOT EXISTS idx_tasks_deleted ON tasks(deleted)`, + `CREATE INDEX IF NOT EXISTS idx_tasks_last_completed_at ON tasks(last_completed_at)`, + } + for _, indexSQL := range createTasksIndexes { + if _, err := a.DB.Exec(indexSQL); err != nil { + log.Printf("Warning: Failed to create tasks index: %v", err) + } + } + + // Apply migration 016: Add repetition_period to tasks + if _, err := a.DB.Exec("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS repetition_period INTERVAL"); err != nil { + log.Printf("Warning: Failed to apply migration 016 (add repetition_period): %v", err) + } + + // Создаем таблицу reward_configs + createRewardConfigsTable := ` + CREATE TABLE IF NOT EXISTS reward_configs ( + id SERIAL PRIMARY KEY, + position INTEGER NOT NULL, + task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE, + project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE, + value NUMERIC(10,4) NOT NULL, + use_progression BOOLEAN DEFAULT FALSE + ) + ` + if _, err := a.DB.Exec(createRewardConfigsTable); err != nil { + return fmt.Errorf("failed to create reward_configs table: %w", err) + } + + // Создаем индексы для reward_configs + createRewardConfigsIndexes := []string{ + `CREATE INDEX IF NOT EXISTS idx_reward_configs_task_id ON reward_configs(task_id)`, + `CREATE INDEX IF NOT EXISTS idx_reward_configs_project_id ON reward_configs(project_id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_reward_configs_task_position ON reward_configs(task_id, position)`, + } + for _, indexSQL := range createRewardConfigsIndexes { + if _, err := a.DB.Exec(indexSQL); err != nil { + log.Printf("Warning: Failed to create reward_configs index: %v", err) + } + } + return nil } @@ -3465,6 +3586,14 @@ func main() { protected.HandleFunc("/api/integrations/todoist/status", app.getTodoistStatusHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/integrations/todoist/disconnect", app.todoistDisconnectHandler).Methods("DELETE", "OPTIONS") + // Tasks + protected.HandleFunc("/api/tasks", app.getTasksHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/tasks", app.createTaskHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/tasks/{id}", app.getTaskDetailHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/tasks/{id}", app.updateTaskHandler).Methods("PUT", "OPTIONS") + protected.HandleFunc("/api/tasks/{id}", app.deleteTaskHandler).Methods("DELETE", "OPTIONS") + protected.HandleFunc("/api/tasks/{id}/complete", app.completeTaskHandler).Methods("POST", "OPTIONS") + // Admin operations protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS") @@ -6100,6 +6229,1198 @@ func (a *App) getTodoistStatusHandler(w http.ResponseWriter, r *http.Request) { }) } +// ============================================ +// Tasks handlers +// ============================================ + +// getTasksHandler возвращает список задач пользователя +func (a *App) getTasksHandler(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 id, name, completed, last_completed_at, repetition_period::text + FROM tasks + WHERE user_id = $1 AND parent_task_id IS NULL AND deleted = FALSE + ORDER BY + CASE WHEN last_completed_at IS NULL OR last_completed_at::date < CURRENT_DATE THEN 0 ELSE 1 END, + name + ` + + rows, err := a.DB.Query(query, userID) + if err != nil { + log.Printf("Error querying tasks: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error querying tasks: %v", err), http.StatusInternalServerError) + return + } + defer rows.Close() + + tasks := make([]Task, 0) + for rows.Next() { + var task Task + var lastCompletedAt sql.NullString + var repetitionPeriod sql.NullString + + err := rows.Scan(&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &repetitionPeriod) + if err != nil { + log.Printf("Error scanning task: %v", err) + continue + } + + if lastCompletedAt.Valid { + task.LastCompletedAt = &lastCompletedAt.String + } + if repetitionPeriod.Valid { + task.RepetitionPeriod = &repetitionPeriod.String + } + + tasks = append(tasks, task) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tasks) +} + +// getTaskDetailHandler возвращает детальную информацию о задаче +func (a *App) getTaskDetailHandler(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 + } + + vars := mux.Vars(r) + taskID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) + return + } + + // Получаем основную задачу + var task Task + var rewardMessage sql.NullString + var progressionBase sql.NullFloat64 + var lastCompletedAt sql.NullString + var repetitionPeriod sql.NullString + + // Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL + var repetitionPeriodStr string + err = a.DB.QueryRow(` + SELECT id, name, completed, last_completed_at, reward_message, progression_base, + CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period + FROM tasks + WHERE id = $1 AND user_id = $2 AND deleted = FALSE + `, taskID, userID).Scan( + &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, + ) + + log.Printf("Scanned repetition_period for task %d: String='%s'", taskID, repetitionPeriodStr) + + // Преобразуем в sql.NullString для совместимости + if repetitionPeriodStr != "" { + repetitionPeriod = sql.NullString{String: repetitionPeriodStr, Valid: true} + } else { + repetitionPeriod = sql.NullString{Valid: false} + } + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Task not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error querying task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error querying task: %v", err), http.StatusInternalServerError) + return + } + + if rewardMessage.Valid { + task.RewardMessage = &rewardMessage.String + } + if progressionBase.Valid { + task.ProgressionBase = &progressionBase.Float64 + } + if lastCompletedAt.Valid { + task.LastCompletedAt = &lastCompletedAt.String + } + if repetitionPeriod.Valid && repetitionPeriod.String != "" { + task.RepetitionPeriod = &repetitionPeriod.String + log.Printf("Task %d has repetition_period: %s", task.ID, repetitionPeriod.String) + } else { + log.Printf("Task %d has no repetition_period (Valid: %v, String: '%s')", task.ID, repetitionPeriod.Valid, repetitionPeriod.String) + } + + // Получаем награды основной задачи + rewards := make([]Reward, 0) + rewardRows, err := a.DB.Query(` + SELECT rc.id, rc.position, p.name AS project_name, rc.value, rc.use_progression + FROM reward_configs rc + JOIN projects p ON rc.project_id = p.id + WHERE rc.task_id = $1 + ORDER BY rc.position + `, taskID) + + if err != nil { + log.Printf("Error querying rewards: %v", err) + } else { + defer rewardRows.Close() + for rewardRows.Next() { + var reward Reward + err := rewardRows.Scan(&reward.ID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) + if err != nil { + log.Printf("Error scanning reward: %v", err) + continue + } + rewards = append(rewards, reward) + } + } + + // Получаем подзадачи + subtasks := make([]Subtask, 0) + subtaskRows, err := a.DB.Query(` + SELECT id, name, completed, last_completed_at, reward_message, progression_base + FROM tasks + WHERE parent_task_id = $1 AND deleted = FALSE + ORDER BY id + `, taskID) + + if err != nil { + log.Printf("Error querying subtasks: %v", err) + } else { + defer subtaskRows.Close() + for subtaskRows.Next() { + var subtaskTask Task + var subtaskRewardMessage sql.NullString + var subtaskProgressionBase sql.NullFloat64 + var subtaskLastCompletedAt sql.NullString + + err := subtaskRows.Scan( + &subtaskTask.ID, &subtaskTask.Name, &subtaskTask.Completed, + &subtaskLastCompletedAt, &subtaskRewardMessage, &subtaskProgressionBase, + ) + if err != nil { + log.Printf("Error scanning subtask: %v", err) + continue + } + + if subtaskRewardMessage.Valid { + subtaskTask.RewardMessage = &subtaskRewardMessage.String + } + if subtaskProgressionBase.Valid { + subtaskTask.ProgressionBase = &subtaskProgressionBase.Float64 + } + if subtaskLastCompletedAt.Valid { + subtaskTask.LastCompletedAt = &subtaskLastCompletedAt.String + } + + // Получаем награды подзадачи + subtaskRewards := make([]Reward, 0) + subtaskRewardRows, err := a.DB.Query(` + SELECT rc.id, rc.position, p.name AS project_name, rc.value, rc.use_progression + FROM reward_configs rc + JOIN projects p ON rc.project_id = p.id + WHERE rc.task_id = $1 + ORDER BY rc.position + `, subtaskTask.ID) + + if err == nil { + for subtaskRewardRows.Next() { + var reward Reward + err := subtaskRewardRows.Scan(&reward.ID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) + if err != nil { + log.Printf("Error scanning subtask reward: %v", err) + continue + } + subtaskRewards = append(subtaskRewards, reward) + } + subtaskRewardRows.Close() + } + + subtasks = append(subtasks, Subtask{ + Task: subtaskTask, + Rewards: subtaskRewards, + }) + } + } + + response := TaskDetail{ + Task: task, + Rewards: rewards, + Subtasks: subtasks, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// findProjectByName находит проект по имени (регистронезависимо) или возвращает ошибку +func (a *App) findProjectByName(projectName string, userID int) (int, error) { + var projectID int + err := a.DB.QueryRow(` + SELECT id FROM projects + WHERE LOWER(name) = LOWER($1) AND user_id = $2 AND deleted = FALSE + `, projectName, userID).Scan(&projectID) + + if err == sql.ErrNoRows { + return 0, fmt.Errorf("project not found: %s", projectName) + } + if err != nil { + return 0, fmt.Errorf("error finding project: %w", err) + } + + return projectID, nil +} + +// findProjectByNameTx находит проект по имени в транзакции +func (a *App) findProjectByNameTx(tx *sql.Tx, projectName string, userID int) (int, error) { + var projectID int + err := tx.QueryRow(` + SELECT id FROM projects + WHERE LOWER(name) = LOWER($1) AND user_id = $2 AND deleted = FALSE + `, projectName, userID).Scan(&projectID) + + if err == sql.ErrNoRows { + return 0, fmt.Errorf("project not found: %s", projectName) + } + if err != nil { + return 0, fmt.Errorf("error finding project: %w", err) + } + + return projectID, nil +} + +// createTaskHandler создает новую задачу +func (a *App) createTaskHandler(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 + } + + var req TaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Error decoding task request: %v", err) + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Валидация + if len(strings.TrimSpace(req.Name)) < 1 { + sendErrorWithCORS(w, "Task name is required and must be at least 1 character", http.StatusBadRequest) + return + } + + // Проверяем, что все rewards имеют project_name + for _, reward := range req.Rewards { + if strings.TrimSpace(reward.ProjectName) == "" { + sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest) + return + } + } + + // Начинаем транзакцию + tx, err := a.DB.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Создаем основную задачу + var taskID int + var rewardMessage sql.NullString + var progressionBase sql.NullFloat64 + var repetitionPeriod sql.NullString + if req.RewardMessage != nil { + rewardMessage = sql.NullString{String: *req.RewardMessage, Valid: true} + } + if req.ProgressionBase != nil { + progressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} + } + if req.RepetitionPeriod != nil && strings.TrimSpace(*req.RepetitionPeriod) != "" { + repetitionPeriod = sql.NullString{String: strings.TrimSpace(*req.RepetitionPeriod), Valid: true} + log.Printf("Creating task with repetition_period: %s", repetitionPeriod.String) + } else { + log.Printf("Creating task without repetition_period (req.RepetitionPeriod: %v)", req.RepetitionPeriod) + } + + // Используем CAST для преобразования строки в INTERVAL + var repetitionPeriodValue interface{} + if repetitionPeriod.Valid { + repetitionPeriodValue = repetitionPeriod.String + } else { + repetitionPeriodValue = nil + } + + // Используем условный SQL для обработки NULL значений + var insertSQL string + var insertArgs []interface{} + if repetitionPeriod.Valid { + insertSQL = ` + INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, completed, deleted) + VALUES ($1, $2, $3, $4, $5::INTERVAL, 0, FALSE) + RETURNING id + ` + insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue} + } else { + insertSQL = ` + INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, completed, deleted) + VALUES ($1, $2, $3, $4, NULL, 0, FALSE) + RETURNING id + ` + insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase} + } + + err = tx.QueryRow(insertSQL, insertArgs...).Scan(&taskID) + + if err != nil { + log.Printf("Error creating task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating task: %v", err), http.StatusInternalServerError) + return + } + + // Создаем награды для основной задачи + for _, rewardReq := range req.Rewards { + projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID) + if err != nil { + log.Printf("Error finding project %s: %v", rewardReq.ProjectName, err) + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) + return + } + + _, err = tx.Exec(` + INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) + VALUES ($1, $2, $3, $4, $5) + `, rewardReq.Position, taskID, projectID, rewardReq.Value, rewardReq.UseProgression) + + if err != nil { + log.Printf("Error creating reward: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating reward: %v", err), http.StatusInternalServerError) + return + } + } + + // Создаем подзадачи + for _, subtaskReq := range req.Subtasks { + var subtaskName sql.NullString + var subtaskRewardMessage sql.NullString + var subtaskProgressionBase sql.NullFloat64 + + if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" { + subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true} + } + if subtaskReq.RewardMessage != nil { + subtaskRewardMessage = sql.NullString{String: *subtaskReq.RewardMessage, Valid: true} + } + if req.ProgressionBase != nil { + subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, 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) + RETURNING id + `, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase).Scan(&subtaskID) + + if err != nil { + log.Printf("Error creating subtask: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask: %v", err), http.StatusInternalServerError) + return + } + + // Создаем награды для подзадачи + for _, rewardReq := range subtaskReq.Rewards { + if strings.TrimSpace(rewardReq.ProjectName) == "" { + sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest) + return + } + + projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID) + if err != nil { + log.Printf("Error finding project %s for subtask: %v", rewardReq.ProjectName, err) + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) + return + } + + _, err = tx.Exec(` + INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) + VALUES ($1, $2, $3, $4, $5) + `, rewardReq.Position, subtaskID, projectID, rewardReq.Value, rewardReq.UseProgression) + + if err != nil { + log.Printf("Error creating subtask reward: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask reward: %v", err), http.StatusInternalServerError) + return + } + } + } + + // Коммитим транзакцию + if err := tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) + return + } + + // Возвращаем созданную задачу + var createdTask Task + var lastCompletedAt sql.NullString + var createdRepetitionPeriod sql.NullString + err = a.DB.QueryRow(` + SELECT id, name, completed, last_completed_at, reward_message, progression_base, repetition_period::text + FROM tasks + WHERE id = $1 + `, taskID).Scan( + &createdTask.ID, &createdTask.Name, &createdTask.Completed, + &lastCompletedAt, &rewardMessage, &progressionBase, &createdRepetitionPeriod, + ) + + if err != nil { + log.Printf("Error fetching created task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error fetching created task: %v", err), http.StatusInternalServerError) + return + } + + if rewardMessage.Valid { + createdTask.RewardMessage = &rewardMessage.String + } + if progressionBase.Valid { + createdTask.ProgressionBase = &progressionBase.Float64 + } + if lastCompletedAt.Valid { + createdTask.LastCompletedAt = &lastCompletedAt.String + } + if createdRepetitionPeriod.Valid { + createdTask.RepetitionPeriod = &createdRepetitionPeriod.String + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(createdTask) +} + +// updateTaskHandler обновляет существующую задачу +func (a *App) updateTaskHandler(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 + } + + vars := mux.Vars(r) + taskID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) + return + } + + // Проверяем владельца + var ownerID int + err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1", taskID).Scan(&ownerID) + if err == sql.ErrNoRows || ownerID != userID { + sendErrorWithCORS(w, "Task not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error checking task ownership: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError) + return + } + + var req TaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Error decoding task request: %v", err) + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Валидация + if len(strings.TrimSpace(req.Name)) < 1 { + sendErrorWithCORS(w, "Task name is required and must be at least 1 character", http.StatusBadRequest) + return + } + + // Проверяем, что все rewards имеют project_name + for _, reward := range req.Rewards { + if strings.TrimSpace(reward.ProjectName) == "" { + sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest) + return + } + } + + // Начинаем транзакцию + tx, err := a.DB.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Обновляем основную задачу + var rewardMessage sql.NullString + var progressionBase sql.NullFloat64 + var repetitionPeriod sql.NullString + if req.RewardMessage != nil { + rewardMessage = sql.NullString{String: *req.RewardMessage, Valid: true} + } + if req.ProgressionBase != nil { + progressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} + } + if req.RepetitionPeriod != nil && strings.TrimSpace(*req.RepetitionPeriod) != "" { + repetitionPeriod = sql.NullString{String: strings.TrimSpace(*req.RepetitionPeriod), Valid: true} + log.Printf("Updating task %d with repetition_period: %s", taskID, repetitionPeriod.String) + } else { + log.Printf("Updating task %d without repetition_period (req.RepetitionPeriod: %v)", taskID, req.RepetitionPeriod) + } + + // Используем условный SQL для обработки NULL значений + var updateSQL string + var updateArgs []interface{} + if repetitionPeriod.Valid { + updateSQL = ` + UPDATE tasks + SET name = $1, reward_message = $2, progression_base = $3, repetition_period = $4::INTERVAL + WHERE id = $5 + ` + updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, taskID} + } else { + updateSQL = ` + UPDATE tasks + SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL + WHERE id = $4 + ` + updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, taskID} + } + + _, err = tx.Exec(updateSQL, updateArgs...) + + if err != nil { + log.Printf("Error updating task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error updating task: %v", err), http.StatusInternalServerError) + return + } + + // Удаляем старые награды основной задачи + _, err = tx.Exec("DELETE FROM reward_configs WHERE task_id = $1", taskID) + if err != nil { + log.Printf("Error deleting old rewards: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error deleting old rewards: %v", err), http.StatusInternalServerError) + return + } + + // Вставляем новые награды + for _, rewardReq := range req.Rewards { + projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID) + if err != nil { + log.Printf("Error finding project %s: %v", rewardReq.ProjectName, err) + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) + return + } + + _, err = tx.Exec(` + INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) + VALUES ($1, $2, $3, $4, $5) + `, rewardReq.Position, taskID, projectID, rewardReq.Value, rewardReq.UseProgression) + + if err != nil { + log.Printf("Error creating reward: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating reward: %v", err), http.StatusInternalServerError) + return + } + } + + // Получаем список текущих подзадач + currentSubtaskIDs := make(map[int]bool) + rows, err := tx.Query("SELECT id FROM tasks WHERE parent_task_id = $1 AND deleted = FALSE", taskID) + if err == nil { + for rows.Next() { + var id int + if err := rows.Scan(&id); err == nil { + currentSubtaskIDs[id] = true + } + } + rows.Close() + } + + // Обрабатываем подзадачи из запроса + subtaskIDsInRequest := make(map[int]bool) + for _, subtaskReq := range req.Subtasks { + if subtaskReq.ID != nil { + subtaskIDsInRequest[*subtaskReq.ID] = true + + // Обновляем существующую подзадачу + var subtaskName sql.NullString + var subtaskRewardMessage sql.NullString + var subtaskProgressionBase sql.NullFloat64 + + if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" { + subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true} + } + if subtaskReq.RewardMessage != nil { + subtaskRewardMessage = sql.NullString{String: *subtaskReq.RewardMessage, Valid: true} + } + if req.ProgressionBase != nil { + subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, 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) + + if err != nil { + log.Printf("Error updating subtask: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error updating subtask: %v", err), http.StatusInternalServerError) + return + } + + // Удаляем старые награды подзадачи + _, err = tx.Exec("DELETE FROM reward_configs WHERE task_id = $1", *subtaskReq.ID) + if err != nil { + log.Printf("Error deleting old subtask rewards: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error deleting old subtask rewards: %v", err), http.StatusInternalServerError) + return + } + + // Вставляем новые награды подзадачи + for _, rewardReq := range subtaskReq.Rewards { + if strings.TrimSpace(rewardReq.ProjectName) == "" { + sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest) + return + } + + projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID) + if err != nil { + log.Printf("Error finding project %s for subtask: %v", rewardReq.ProjectName, err) + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) + return + } + + _, err = tx.Exec(` + INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) + VALUES ($1, $2, $3, $4, $5) + `, rewardReq.Position, *subtaskReq.ID, projectID, rewardReq.Value, rewardReq.UseProgression) + + if err != nil { + log.Printf("Error creating subtask reward: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask reward: %v", err), http.StatusInternalServerError) + return + } + } + } else { + // Создаем новую подзадачу + var subtaskName sql.NullString + var subtaskRewardMessage sql.NullString + var subtaskProgressionBase sql.NullFloat64 + + if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" { + subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true} + } + if subtaskReq.RewardMessage != nil { + subtaskRewardMessage = sql.NullString{String: *subtaskReq.RewardMessage, Valid: true} + } + if req.ProgressionBase != nil { + subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, 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) + RETURNING id + `, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase).Scan(&subtaskID) + + if err != nil { + log.Printf("Error creating subtask: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask: %v", err), http.StatusInternalServerError) + return + } + + // Создаем награды для новой подзадачи + for _, rewardReq := range subtaskReq.Rewards { + if strings.TrimSpace(rewardReq.ProjectName) == "" { + sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest) + return + } + + projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID) + if err != nil { + log.Printf("Error finding project %s for new subtask: %v", rewardReq.ProjectName, err) + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) + return + } + + _, err = tx.Exec(` + INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) + VALUES ($1, $2, $3, $4, $5) + `, rewardReq.Position, subtaskID, projectID, rewardReq.Value, rewardReq.UseProgression) + + if err != nil { + log.Printf("Error creating subtask reward: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask reward: %v", err), http.StatusInternalServerError) + return + } + } + } + } + + // Помечаем подзадачи, которые были в БД, но не пришли в запросе, как deleted + for subtaskID := range currentSubtaskIDs { + if !subtaskIDsInRequest[subtaskID] { + _, err = tx.Exec("UPDATE tasks SET deleted = TRUE WHERE id = $1", subtaskID) + if err != nil { + log.Printf("Error marking subtask as deleted: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error marking subtask as deleted: %v", err), http.StatusInternalServerError) + return + } + } + } + + // Коммитим транзакцию + if err := tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) + return + } + + // Возвращаем обновленную задачу + var updatedTask Task + var lastCompletedAt sql.NullString + var updatedRepetitionPeriod sql.NullString + err = a.DB.QueryRow(` + SELECT id, name, completed, last_completed_at, reward_message, progression_base, repetition_period::text + FROM tasks + WHERE id = $1 + `, taskID).Scan( + &updatedTask.ID, &updatedTask.Name, &updatedTask.Completed, + &lastCompletedAt, &rewardMessage, &progressionBase, &updatedRepetitionPeriod, + ) + + if err != nil { + log.Printf("Error fetching updated task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error fetching updated task: %v", err), http.StatusInternalServerError) + return + } + + if rewardMessage.Valid { + updatedTask.RewardMessage = &rewardMessage.String + } + if progressionBase.Valid { + updatedTask.ProgressionBase = &progressionBase.Float64 + } + if lastCompletedAt.Valid { + updatedTask.LastCompletedAt = &lastCompletedAt.String + } + if updatedRepetitionPeriod.Valid { + updatedTask.RepetitionPeriod = &updatedRepetitionPeriod.String + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(updatedTask) +} + +// deleteTaskHandler удаляет задачу (помечает как deleted) +func (a *App) deleteTaskHandler(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 + } + + vars := mux.Vars(r) + taskID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) + return + } + + // Проверяем владельца + var ownerID int + err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1", taskID).Scan(&ownerID) + if err == sql.ErrNoRows || ownerID != userID { + sendErrorWithCORS(w, "Task not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error checking task ownership: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError) + return + } + + // Помечаем задачу как удаленную + _, err = a.DB.Exec("UPDATE tasks SET deleted = TRUE WHERE id = $1 AND user_id = $2", taskID, userID) + if err != nil { + log.Printf("Error deleting task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error deleting task: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Task deleted successfully", + }) +} + +// completeTaskHandler выполняет задачу +func (a *App) completeTaskHandler(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 + } + + vars := mux.Vars(r) + taskID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) + return + } + + var req CompleteTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Error decoding complete task request: %v", err) + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Получаем задачу и проверяем владельца + var task Task + var rewardMessage sql.NullString + var progressionBase sql.NullFloat64 + var repetitionPeriod sql.NullString + var ownerID int + + err = a.DB.QueryRow(` + SELECT id, name, reward_message, progression_base, repetition_period, user_id + FROM tasks + WHERE id = $1 AND deleted = FALSE + `, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &ownerID) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Task not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error querying task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error querying task: %v", err), http.StatusInternalServerError) + return + } + + if ownerID != userID { + sendErrorWithCORS(w, "Task not found", http.StatusNotFound) + return + } + + // Валидация: если progression_base != null, то value обязателен + if progressionBase.Valid && req.Value == nil { + sendErrorWithCORS(w, "Value is required when progression_base is set", http.StatusBadRequest) + return + } + + if rewardMessage.Valid { + task.RewardMessage = &rewardMessage.String + } + if progressionBase.Valid { + task.ProgressionBase = &progressionBase.Float64 + } + + // Получаем награды основной задачи + rewardRows, err := a.DB.Query(` + SELECT rc.position, p.name AS project_name, rc.value, rc.use_progression + FROM reward_configs rc + JOIN projects p ON rc.project_id = p.id + WHERE rc.task_id = $1 + ORDER BY rc.position + `, taskID) + + if err != nil { + log.Printf("Error querying rewards: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error querying rewards: %v", err), http.StatusInternalServerError) + return + } + defer rewardRows.Close() + + rewards := make([]Reward, 0) + for rewardRows.Next() { + var reward Reward + err := rewardRows.Scan(&reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) + if err != nil { + log.Printf("Error scanning reward: %v", err) + continue + } + rewards = append(rewards, reward) + } + + // Вычисляем score для каждой награды и формируем строки для подстановки + rewardStrings := make(map[int]string) + for _, reward := range rewards { + var score float64 + if reward.UseProgression && progressionBase.Valid && req.Value != nil { + score = (*req.Value / progressionBase.Float64) * reward.Value + } else { + score = reward.Value + } + + // Формируем строку награды + var rewardStr string + if score >= 0 { + rewardStr = fmt.Sprintf("**%s+%.4g**", reward.ProjectName, score) + } else { + // Убираем знак минуса из числа (используем абсолютное значение) + rewardStr = fmt.Sprintf("**%s-%.4g**", reward.ProjectName, math.Abs(score)) + } + rewardStrings[reward.Position] = rewardStr + } + + // Подставляем в reward_message основной задачи + var mainTaskMessage string + if task.RewardMessage != nil && *task.RewardMessage != "" { + mainTaskMessage = *task.RewardMessage + // Заменяем плейсхолдеры ${0}, ${1}, и т.д. + for i := 0; i < 100; i++ { // Максимум 100 плейсхолдеров + placeholder := fmt.Sprintf("${%d}", i) + if rewardStr, ok := rewardStrings[i]; ok { + mainTaskMessage = strings.ReplaceAll(mainTaskMessage, placeholder, rewardStr) + } + } + } else { + // Если reward_message пустой, используем имя задачи + mainTaskMessage = task.Name + } + + // Получаем выбранные подзадачи (только с непустым reward_message и deleted = FALSE) + subtaskMessages := make([]string, 0) + if len(req.ChildrenTaskIDs) > 0 { + placeholders := make([]string, len(req.ChildrenTaskIDs)) + args := make([]interface{}, len(req.ChildrenTaskIDs)+1) + args[0] = taskID + for i, id := range req.ChildrenTaskIDs { + placeholders[i] = fmt.Sprintf("$%d", i+2) + args[i+1] = id + } + + query := fmt.Sprintf(` + SELECT id, name, reward_message, progression_base + FROM tasks + WHERE parent_task_id = $1 AND id IN (%s) AND deleted = FALSE + `, strings.Join(placeholders, ",")) + + subtaskRows, err := a.DB.Query(query, args...) + if err != nil { + log.Printf("Error querying subtasks: %v", err) + } else { + defer subtaskRows.Close() + for subtaskRows.Next() { + var subtaskID int + var subtaskName string + var subtaskRewardMessage sql.NullString + var subtaskProgressionBase sql.NullFloat64 + + err := subtaskRows.Scan(&subtaskID, &subtaskName, &subtaskRewardMessage, &subtaskProgressionBase) + if err != nil { + log.Printf("Error scanning subtask: %v", err) + continue + } + + // Пропускаем подзадачи с пустым reward_message + if !subtaskRewardMessage.Valid || subtaskRewardMessage.String == "" { + continue + } + + // Получаем награды подзадачи + subtaskRewardRows, err := a.DB.Query(` + SELECT rc.position, p.name AS project_name, rc.value, rc.use_progression + FROM reward_configs rc + JOIN projects p ON rc.project_id = p.id + WHERE rc.task_id = $1 + ORDER BY rc.position + `, subtaskID) + + if err != nil { + log.Printf("Error querying subtask rewards: %v", err) + continue + } + + subtaskRewards := make([]Reward, 0) + for subtaskRewardRows.Next() { + var reward Reward + err := subtaskRewardRows.Scan(&reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) + if err != nil { + log.Printf("Error scanning subtask reward: %v", err) + continue + } + subtaskRewards = append(subtaskRewards, reward) + } + subtaskRewardRows.Close() + + // Вычисляем score для наград подзадачи + subtaskRewardStrings := make(map[int]string) + for _, reward := range subtaskRewards { + var score float64 + if reward.UseProgression && subtaskProgressionBase.Valid && req.Value != nil { + score = (*req.Value / subtaskProgressionBase.Float64) * reward.Value + } else if reward.UseProgression && progressionBase.Valid && req.Value != nil { + // Если у подзадачи нет progression_base, используем основной + score = (*req.Value / progressionBase.Float64) * reward.Value + } else { + score = reward.Value + } + + var rewardStr string + if score >= 0 { + rewardStr = fmt.Sprintf("**%s+%.4g**", reward.ProjectName, score) + } else { + rewardStr = fmt.Sprintf("**%s-%.4g**", reward.ProjectName, math.Abs(score)) + } + subtaskRewardStrings[reward.Position] = rewardStr + } + + // Подставляем в reward_message подзадачи + subtaskMessage := subtaskRewardMessage.String + for i := 0; i < 100; i++ { + placeholder := fmt.Sprintf("${%d}", i) + if rewardStr, ok := subtaskRewardStrings[i]; ok { + subtaskMessage = strings.ReplaceAll(subtaskMessage, placeholder, rewardStr) + } + } + + subtaskMessages = append(subtaskMessages, subtaskMessage) + } + } + } + + // Формируем итоговое сообщение + var finalMessage strings.Builder + finalMessage.WriteString(mainTaskMessage) + for _, subtaskMsg := range subtaskMessages { + finalMessage.WriteString("\n + ") + finalMessage.WriteString(subtaskMsg) + } + + // Отправляем сообщение через processMessage + userIDPtr := &userID + _, err = a.processMessage(finalMessage.String(), userIDPtr) + if err != nil { + // Логируем ошибку, но не откатываем транзакцию + log.Printf("Error sending message to Telegram: %v", err) + } + + // Обновляем completed и last_completed_at для основной задачи + // Если repetition_period не установлен, помечаем задачу как удаленную + // Если repetition_period = "0 day" (или любое значение с 0), не обновляем last_completed_at + if repetitionPeriod.Valid { + // Проверяем, является ли период нулевым (начинается с "0 ") + periodStr := strings.TrimSpace(repetitionPeriod.String) + isZeroPeriod := strings.HasPrefix(periodStr, "0 ") || periodStr == "0" + + if isZeroPeriod { + // Период = 0: обновляем только счетчик, но не last_completed_at + // Задача никогда не будет переноситься в выполненные + _, err = a.DB.Exec(` + UPDATE tasks + SET completed = completed + 1 + WHERE id = $1 + `, taskID) + } else { + // Обычный период: обновляем счетчик и last_completed_at + _, err = a.DB.Exec(` + UPDATE tasks + SET completed = completed + 1, last_completed_at = NOW() + WHERE id = $1 + `, taskID) + } + } else { + _, err = a.DB.Exec(` + UPDATE tasks + SET completed = completed + 1, last_completed_at = NOW(), deleted = TRUE + WHERE id = $1 + `, taskID) + } + + if err != nil { + log.Printf("Error updating task completion: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error updating task completion: %v", err), http.StatusInternalServerError) + return + } + + // Обновляем выбранные подзадачи + if len(req.ChildrenTaskIDs) > 0 { + placeholders := make([]string, len(req.ChildrenTaskIDs)) + args := make([]interface{}, len(req.ChildrenTaskIDs)) + for i, id := range req.ChildrenTaskIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + + query := fmt.Sprintf(` + UPDATE tasks + SET completed = completed + 1, last_completed_at = NOW() + WHERE id IN (%s) AND deleted = FALSE + `, strings.Join(placeholders, ",")) + + _, err = a.DB.Exec(query, args...) + if err != nil { + log.Printf("Error updating subtasks completion: %v", err) + // Не возвращаем ошибку, основная задача уже обновлена + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Task completed successfully", + }) +} + // todoistDisconnectHandler отключает интеграцию Todoist func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { diff --git a/play-life-backend/migrations/015_add_tasks.sql b/play-life-backend/migrations/015_add_tasks.sql new file mode 100644 index 0000000..fc4aec4 --- /dev/null +++ b/play-life-backend/migrations/015_add_tasks.sql @@ -0,0 +1,58 @@ +-- Migration: Add tasks and reward_configs tables +-- This script creates tables for task management system + +-- ============================================ +-- Table: tasks +-- ============================================ +CREATE TABLE IF NOT EXISTS tasks ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + completed INTEGER DEFAULT 0, + last_completed_at TIMESTAMP WITH TIME ZONE, + parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE, + reward_message TEXT, + progression_base NUMERIC(10,4), + deleted BOOLEAN DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id); +CREATE INDEX IF NOT EXISTS idx_tasks_parent_task_id ON tasks(parent_task_id); +CREATE INDEX IF NOT EXISTS idx_tasks_deleted ON tasks(deleted); +CREATE INDEX IF NOT EXISTS idx_tasks_last_completed_at ON tasks(last_completed_at); + +-- ============================================ +-- Table: reward_configs +-- ============================================ +CREATE TABLE IF NOT EXISTS reward_configs ( + id SERIAL PRIMARY KEY, + position INTEGER NOT NULL, + task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE, + project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE, + value NUMERIC(10,4) NOT NULL, + use_progression BOOLEAN DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_reward_configs_task_id ON reward_configs(task_id); +CREATE INDEX IF NOT EXISTS idx_reward_configs_project_id ON reward_configs(project_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_reward_configs_task_position ON reward_configs(task_id, position); + +-- ============================================ +-- Comments for documentation +-- ============================================ +COMMENT ON TABLE tasks IS 'Tasks table for task management system'; +COMMENT ON COLUMN tasks.name IS 'Task name (required for main tasks, optional for subtasks)'; +COMMENT ON COLUMN tasks.completed IS 'Number of times task was completed'; +COMMENT ON COLUMN tasks.last_completed_at IS 'Date and time of last task completion'; +COMMENT ON COLUMN tasks.parent_task_id IS 'Parent task ID for subtasks (NULL for main tasks)'; +COMMENT ON COLUMN tasks.reward_message IS 'Reward message template with placeholders ${0}, ${1}, etc.'; +COMMENT ON COLUMN tasks.progression_base IS 'Base value for progression calculation (NULL means no progression)'; +COMMENT ON COLUMN tasks.deleted IS 'Soft delete flag'; + +COMMENT ON TABLE reward_configs IS 'Reward configurations for tasks'; +COMMENT ON COLUMN reward_configs.position IS 'Position in reward_message template (0, 1, 2, etc.)'; +COMMENT ON COLUMN reward_configs.task_id IS 'Task this reward belongs to'; +COMMENT ON COLUMN reward_configs.project_id IS 'Project to add reward to'; +COMMENT ON COLUMN reward_configs.value IS 'Default score value (can be negative)'; +COMMENT ON COLUMN reward_configs.use_progression IS 'Whether to use progression multiplier for this reward'; + diff --git a/play-life-backend/migrations/016_add_repetition_period.sql b/play-life-backend/migrations/016_add_repetition_period.sql new file mode 100644 index 0000000..4af049d --- /dev/null +++ b/play-life-backend/migrations/016_add_repetition_period.sql @@ -0,0 +1,14 @@ +-- Migration: Add repetition_period field to tasks table +-- This script adds the repetition_period field for recurring tasks + +-- ============================================ +-- Add repetition_period column +-- ============================================ +ALTER TABLE tasks +ADD COLUMN IF NOT EXISTS repetition_period INTERVAL; + +-- ============================================ +-- Comments for documentation +-- ============================================ +COMMENT ON COLUMN tasks.repetition_period IS 'Period after which task should be repeated (NULL means task is not recurring)'; + diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index 92abfd8..23641c4 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -9,7 +9,7 @@ import AddConfig from './components/AddConfig' import TestWords from './components/TestWords' import Profile from './components/Profile' import TaskList from './components/TaskList' -import TaskForm from './components/TaskForm' +import TaskForm from './components/TaskForm.jsx' import { AuthProvider, useAuth } from './components/auth/AuthContext' import AuthScreen from './components/auth/AuthScreen' diff --git a/play-life-web/src/components/TaskForm.css b/play-life-web/src/components/TaskForm.css new file mode 100644 index 0000000..2b3a3dc --- /dev/null +++ b/play-life-web/src/components/TaskForm.css @@ -0,0 +1,370 @@ +.task-form { + padding: 1rem; + max-width: 800px; + margin: 0 auto; + position: relative; +} + +.close-x-button { + position: absolute; + top: 1rem; + right: 1rem; + background: #f3f4f6; + border: 1px solid #e5e7eb; + border-radius: 50%; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 1.25rem; + color: #6b7280; + transition: all 0.2s; +} + +.close-x-button:hover { + background: #e5e7eb; + color: #1f2937; +} + +.task-form h2 { + font-size: 1.5rem; + font-weight: 600; + color: #1f2937; + margin: 0 0 1.5rem 0; +} + +.task-form form { + background: white; + border-radius: 0.5rem; + padding: 1.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + font-weight: 500; + color: #374151; + margin-bottom: 0.5rem; +} + +.form-input, +.form-textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-size: 1rem; + transition: all 0.2s; +} + +.form-input:focus, +.form-textarea:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.form-textarea { + resize: vertical; + min-height: 80px; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: normal; + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + margin-right: 0.5rem; +} + +.form-group label input[type="checkbox"] { + margin-right: 0.5rem; +} + +.progression-button { + padding: 0.5rem; + border: 2px solid #d1d5db; + border-radius: 0.375rem; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + min-width: 2.5rem; + height: 2.5rem; + background: transparent; + color: #6b7280; +} + +.progression-button-outlined { + background: transparent; + color: #6b7280; + border-color: #d1d5db; +} + +.progression-button-filled { + background: #10b981; + color: white; + border-color: #10b981; +} + +.progression-button:hover { + background: #f3f4f6; + color: #6b7280; + border-color: #9ca3af; +} + +.progression-button-filled:hover { + background: #059669; + border-color: #059669; +} + +.progression-button:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2); +} + +.progression-button-outlined:focus { + background: transparent !important; + color: #6b7280 !important; + border-color: #d1d5db !important; +} + +.progression-button-filled:focus { + background: #10b981 !important; + color: white !important; + border-color: #10b981 !important; +} + +.progression-button-subtask.progression-button-filled { + background: #10b981; + color: white; + border-color: #10b981; +} + +.progression-button-subtask.progression-button-filled:hover { + background: #059669; + border-color: #059669; +} + +.progression-button-subtask.progression-button-filled:focus { + background: #10b981 !important; + color: white !important; + border-color: #10b981 !important; +} + +.rewards-container { + margin-top: 0.75rem; +} + +.reward-item { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.75rem; +} + +.reward-item:last-child { + margin-bottom: 0; +} + +.reward-number { + display: flex; + align-items: center; + justify-content: center; + min-width: 2rem; + height: 2rem; + background: #f3f4f6; + border-radius: 0.375rem; + font-size: 1rem; + font-weight: 600; + color: #6b7280; + flex-shrink: 0; +} + +.subtask-name-input { + margin-bottom: 0.75rem; +} + +.reward-item .form-input { + flex: 1; +} + +.reward-item .reward-project-input { + flex: 3; +} + +.reward-item .reward-score-input { + flex: 1; +} + +.subtasks-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.subtasks-header label { + margin: 0; + display: flex; + align-items: center; + height: 2rem; + line-height: 2rem; +} + +.add-subtask-button { + padding: 0.375rem; + background: #6366f1; + color: white; + border: none; + border-radius: 0.375rem; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + min-width: 2rem; + height: 2rem; +} + +.add-subtask-button:hover { + background: #4f46e5; +} + +.subtask-form-item { + padding: 1rem; + background: #f9fafb; + border-radius: 0.375rem; + border: 1px solid #e5e7eb; + margin-bottom: 1rem; +} + +.subtask-header-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.75rem; +} + +.subtask-name-input { + flex: 1; + margin-bottom: 0; +} + +.subtask-rewards { + margin-top: 0.75rem; +} + +.remove-subtask-button { + padding: 0.5rem; + background: #ef4444; + color: white; + border: none; + border-radius: 0.375rem; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + min-width: 2.5rem; + height: 2.5rem; +} + +.remove-subtask-button:hover { + background: #dc2626; +} + +.error-message { + color: #ef4444; + margin-bottom: 1rem; + padding: 0.75rem; + background: #fef2f2; + border-radius: 0.375rem; + border: 1px solid #fecaca; +} + +.form-actions { + display: flex; + gap: 1rem; + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid #e5e7eb; +} + +.cancel-button, +.submit-button, +.delete-button { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 0.375rem; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.cancel-button { + background: #f3f4f6; + color: #374151; +} + +.cancel-button:hover { + background: #e5e7eb; +} + +.submit-button { + background: linear-gradient(to right, #6366f1, #8b5cf6); + color: white; + flex: 1; +} + +.submit-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); +} + +.submit-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.delete-button { + background: #ef4444; + color: white; + padding: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + min-width: 44px; + width: 44px; +} + +.delete-button:hover:not(:disabled) { + background: #dc2626; +} + +.delete-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.loading { + text-align: center; + padding: 3rem 1rem; + color: #6b7280; +} + diff --git a/play-life-web/src/components/TaskForm.jsx b/play-life-web/src/components/TaskForm.jsx new file mode 100644 index 0000000..292e751 --- /dev/null +++ b/play-life-web/src/components/TaskForm.jsx @@ -0,0 +1,744 @@ +import React, { useState, useEffect, useRef } from 'react' +import { useAuth } from './auth/AuthContext' +import './TaskForm.css' + +const API_URL = '/api/tasks' +const PROJECTS_API_URL = '/projects' + +function TaskForm({ onNavigate, taskId }) { + const { authFetch } = useAuth() + const [name, setName] = useState('') + const [progressionBase, setProgressionBase] = useState('') + const [rewardMessage, setRewardMessage] = useState('') + const [repetitionPeriodValue, setRepetitionPeriodValue] = useState('') + const [repetitionPeriodType, setRepetitionPeriodType] = useState('day') + const [rewards, setRewards] = useState([]) + const [subtasks, setSubtasks] = useState([]) + const [projects, setProjects] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [loadingTask, setLoadingTask] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const debounceTimer = useRef(null) + + // Загрузка проектов для автокомплита + useEffect(() => { + const loadProjects = async () => { + try { + const response = await authFetch(PROJECTS_API_URL) + if (response.ok) { + const data = await response.json() + setProjects(Array.isArray(data) ? data : []) + } + } catch (err) { + console.error('Error loading projects:', err) + } + } + loadProjects() + }, []) + + // Функция сброса формы + const resetForm = () => { + setName('') + setRewardMessage('') + setProgressionBase('') + setRepetitionPeriodValue('') + setRepetitionPeriodType('day') + setRewards([]) + setSubtasks([]) + setError('') + setLoadingTask(false) + if (debounceTimer.current) { + clearTimeout(debounceTimer.current) + debounceTimer.current = null + } + } + + // Загрузка задачи при редактировании или сброс формы при создании новой + useEffect(() => { + if (taskId !== undefined && taskId !== null) { + loadTask() + } else { + // Сбрасываем форму при создании новой задачи + resetForm() + } + }, [taskId]) + + const loadTask = async () => { + setLoadingTask(true) + try { + const response = await authFetch(`${API_URL}/${taskId}`) + if (!response.ok) { + throw new Error('Ошибка загрузки задачи') + } + const data = await response.json() + setName(data.task.name) + setRewardMessage(data.task.reward_message || '') + setProgressionBase(data.task.progression_base ? String(data.task.progression_base) : '') + + // Парсим repetition_period если он есть + if (data.task.repetition_period) { + const periodStr = data.task.repetition_period.trim() + console.log('Parsing repetition_period:', periodStr, 'Full task data:', data.task) // Отладка + + // PostgreSQL может возвращать INTERVAL в разных форматах: + // - "1 day" / "1 days" / "10 days" + // - "02:00:00" (часы в формате времени) + // - "21 days" (недели преобразуются в дни) + // - "1 month" / "1 months" / "1 mon" + + let parsed = false + + // Пробуем парсить формат "N unit" или "N units" + // Используем более гибкий regex для парсинга + const match = periodStr.match(/^(\d+)\s+(minute|minutes|hour|hours|day|days|week|weeks|month|months|mon|year|years)/i) + if (match) { + const value = parseInt(match[1], 10) + const unit = match[2].toLowerCase() + + console.log('Matched value:', value, 'unit:', unit) // Отладка + + if (!isNaN(value) && value >= 0) { + // Преобразуем единицы PostgreSQL в наш формат + if (unit.startsWith('minute')) { + setRepetitionPeriodValue(String(value)) + setRepetitionPeriodType('minute') + parsed = true + } else if (unit.startsWith('hour')) { + setRepetitionPeriodValue(String(value)) + setRepetitionPeriodType('hour') + parsed = true + } else if (unit.startsWith('day')) { + // Может быть "1 day" или "10 days" или "21 days" (для недель) + // Если значение кратно 7, это может быть неделя + if (value % 7 === 0 && value >= 7) { + setRepetitionPeriodValue(String(value / 7)) + setRepetitionPeriodType('week') + } else { + setRepetitionPeriodValue(String(value)) + setRepetitionPeriodType('day') + } + parsed = true + } else if (unit.startsWith('week')) { + setRepetitionPeriodValue(String(value)) + setRepetitionPeriodType('week') + parsed = true + } else if (unit.startsWith('month') || unit.startsWith('mon')) { + // PostgreSQL возвращает "1 mon" для месяцев + setRepetitionPeriodValue(String(value)) + setRepetitionPeriodType('month') + parsed = true + } else if (unit.startsWith('year')) { + setRepetitionPeriodValue(String(value)) + setRepetitionPeriodType('year') + parsed = true + } + } + } else { + // Если regex не сработал, пробуем старый способ через split + const parts = periodStr.split(/\s+/) + if (parts.length >= 2) { + const value = parseInt(parts[0], 10) + if (!isNaN(value) && value >= 0) { + const unit = parts[1].toLowerCase() + console.log('Fallback parsing - value:', value, 'unit:', unit) // Отладка + + if (unit.startsWith('minute')) { + setRepetitionPeriodValue(String(value)) + setRepetitionPeriodType('minute') + parsed = true + } else if (unit.startsWith('hour')) { + setRepetitionPeriodValue(String(value)) + setRepetitionPeriodType('hour') + parsed = true + } else if (unit.startsWith('day')) { + if (value % 7 === 0 && value >= 7) { + setRepetitionPeriodValue(String(value / 7)) + setRepetitionPeriodType('week') + } else { + setRepetitionPeriodValue(String(value)) + setRepetitionPeriodType('day') + } + parsed = true + } else if (unit.startsWith('week')) { + setRepetitionPeriodValue(String(value)) + setRepetitionPeriodType('week') + parsed = true + } else if (unit.startsWith('month') || unit.startsWith('mon')) { + setRepetitionPeriodValue(String(value)) + setRepetitionPeriodType('month') + parsed = true + } else if (unit.startsWith('year')) { + setRepetitionPeriodValue(String(value)) + setRepetitionPeriodType('year') + parsed = true + } + } + } + } + + // Если не удалось распарсить, пробуем формат времени "HH:MM:SS" + if (!parsed && /^\d{1,2}:\d{2}:\d{2}/.test(periodStr)) { + const timeParts = periodStr.split(':') + if (timeParts.length >= 3) { + const hours = parseInt(timeParts[0], 10) + if (!isNaN(hours) && hours >= 0) { + setRepetitionPeriodValue(String(hours)) + setRepetitionPeriodType('hour') + parsed = true + } + } + } + + // Если не удалось распарсить, сбрасываем значения + if (!parsed) { + console.log('Failed to parse repetition_period:', periodStr) // Отладка + setRepetitionPeriodValue('') + setRepetitionPeriodType('day') + } else { + console.log('Successfully parsed repetition_period - value will be set') // Отладка + } + } else { + console.log('No repetition_period in task data') // Отладка + setRepetitionPeriodValue('') + setRepetitionPeriodType('day') + } + + // Загружаем rewards + setRewards(data.rewards.map(r => ({ + position: r.position, + project_name: r.project_name, + value: String(r.value), + use_progression: r.use_progression + }))) + + // Загружаем подзадачи + setSubtasks(data.subtasks.map(st => ({ + id: st.task.id, + name: st.task.name || '', + reward_message: st.task.reward_message || '', + rewards: st.rewards.map(r => ({ + position: r.position, + project_name: r.project_name, + value: String(r.value), + use_progression: r.use_progression + })) + }))) + } catch (err) { + setError(err.message) + } finally { + setLoadingTask(false) + } + } + + // Пересчет rewards при изменении reward_message (debounce) + useEffect(() => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current) + } + + debounceTimer.current = setTimeout(() => { + const maxIndex = findMaxPlaceholderIndex(rewardMessage) + const currentRewards = [...rewards] + + // Удаляем лишние rewards + while (currentRewards.length > maxIndex + 1) { + currentRewards.pop() + } + + // Добавляем недостающие rewards + while (currentRewards.length < maxIndex + 1) { + currentRewards.push({ + position: currentRewards.length, + project_name: '', + value: '0', + use_progression: false + }) + } + + setRewards(currentRewards) + }, 500) + + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current) + } + } + }, [rewardMessage]) + + const findMaxPlaceholderIndex = (message) => { + if (!message) return -1 + const matches = message.match(/\$\{(\d+)\}/g) + if (!matches) return -1 + const indices = matches.map(m => parseInt(m.match(/\d+/)[0])) + return Math.max(...indices) + } + + + const handleRewardChange = (index, field, value) => { + const newRewards = [...rewards] + newRewards[index] = { ...newRewards[index], [field]: value } + setRewards(newRewards) + } + + const handleRewardProgressionToggle = (index, checked) => { + const newRewards = [...rewards] + newRewards[index] = { ...newRewards[index], use_progression: checked } + setRewards(newRewards) + } + + const handleAddSubtask = () => { + setSubtasks([...subtasks, { + id: null, + name: '', + reward_message: '', + rewards: [] + }]) + } + + const handleSubtaskChange = (index, field, value) => { + const newSubtasks = [...subtasks] + newSubtasks[index] = { ...newSubtasks[index], [field]: value } + setSubtasks(newSubtasks) + } + + const handleSubtaskRewardMessageChange = (index, value) => { + const newSubtasks = [...subtasks] + newSubtasks[index] = { ...newSubtasks[index], reward_message: value } + + // Пересчитываем rewards для подзадачи + const maxIndex = findMaxPlaceholderIndex(value) + const currentRewards = newSubtasks[index].rewards || [] + const newRewards = [...currentRewards] + + while (newRewards.length < maxIndex + 1) { + newRewards.push({ + position: newRewards.length, + project_name: '', + value: '0', + use_progression: false + }) + } + + while (newRewards.length > maxIndex + 1) { + newRewards.pop() + } + + newSubtasks[index] = { ...newSubtasks[index], rewards: newRewards } + setSubtasks(newSubtasks) + } + + const handleRemoveSubtask = (index) => { + setSubtasks(subtasks.filter((_, i) => i !== index)) + } + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + setLoading(true) + + // Валидация + if (!name.trim() || name.trim().length < 1) { + setError('Название задачи обязательно (минимум 1 символ)') + setLoading(false) + return + } + + // Проверяем, что все rewards заполнены + for (const reward of rewards) { + if (!reward.project_name.trim()) { + setError('Все проекты в наградах должны быть заполнены') + setLoading(false) + return + } + } + + try { + // Преобразуем период повторения в строку INTERVAL для PostgreSQL + let repetitionPeriod = null + if (repetitionPeriodValue && repetitionPeriodValue.trim() !== '') { + const value = parseInt(repetitionPeriodValue.trim(), 10) + if (!isNaN(value) && value >= 0) { + const typeMap = { + 'minute': 'minute', + 'hour': 'hour', + 'day': 'day', + 'week': 'week', + 'month': 'month', + 'year': 'year' + } + const unit = typeMap[repetitionPeriodType] || 'day' + repetitionPeriod = `${value} ${unit}` + console.log('Sending repetition_period:', repetitionPeriod, 'from value:', repetitionPeriodValue, 'type:', repetitionPeriodType) + } + } else { + console.log('No repetition_period to send (value:', repetitionPeriodValue, 'type:', repetitionPeriodType, ')') + } + + const payload = { + name: name.trim(), + reward_message: rewardMessage.trim() || null, + progression_base: progressionBase ? parseFloat(progressionBase) : null, + repetition_period: repetitionPeriod, + rewards: rewards.map(r => ({ + position: r.position, + project_name: r.project_name.trim(), + value: parseFloat(r.value) || 0, + use_progression: !!(progressionBase && r.use_progression) + })), + subtasks: subtasks.map(st => ({ + id: st.id || undefined, + name: st.name.trim() || null, + reward_message: st.reward_message.trim() || null, + rewards: st.rewards.map(r => ({ + position: r.position, + project_name: r.project_name.trim(), + value: parseFloat(r.value) || 0, + use_progression: !!(progressionBase && r.use_progression) + })) + })) + } + + const url = taskId ? `${API_URL}/${taskId}` : API_URL + const method = taskId ? 'PUT' : 'POST' + + const response = await authFetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + let errorMessage = 'Ошибка при сохранении задачи' + try { + const errorData = await response.json() + errorMessage = errorData.message || errorData.error || errorMessage + } catch (e) { + // Если не удалось распарсить JSON, используем текст ответа + const text = await response.text().catch(() => '') + if (text) { + errorMessage = text + } + } + throw new Error(errorMessage) + } + + // Возвращаемся к списку задач + onNavigate?.('tasks') + } catch (err) { + setError(err.message) + console.error('Error saving task:', err) + } finally { + setLoading(false) + } + } + + const handleCancel = () => { + resetForm() + onNavigate?.('tasks') + } + + const handleDelete = async () => { + if (!taskId) return + + if (!window.confirm(`Вы уверены, что хотите удалить задачу "${name}"?`)) { + return + } + + setIsDeleting(true) + try { + const response = await authFetch(`${API_URL}/${taskId}`, { + method: 'DELETE', + }) + + if (!response.ok) { + throw new Error('Ошибка при удалении задачи') + } + + // Возвращаемся к списку задач + onNavigate?.('tasks') + } catch (err) { + console.error('Error deleting task:', err) + setError('Ошибка при удалении задачи') + setIsDeleting(false) + } + } + + if (loadingTask) { + return ( +
+
Загрузка...
+
+ ) + } + + return ( +
+ +

{taskId ? 'Редактировать задачу' : 'Новая задача'}

+ +
+
+ + setName(e.target.value)} + required + minLength={1} + className="form-input" + /> +
+ +
+ + setProgressionBase(e.target.value)} + placeholder="Базовое значение" + className="form-input" + /> + + Оставьте пустым, если прогрессия не используется + +
+ +
+ +
+ setRepetitionPeriodValue(e.target.value)} + placeholder="Число" + className="form-input" + style={{ flex: '1' }} + /> + {repetitionPeriodValue && repetitionPeriodValue.trim() !== '' && parseInt(repetitionPeriodValue.trim(), 10) !== 0 && ( + + )} +
+ + Оставьте пустым, если задача не повторяется. Введите 0, если задача никогда не переносится в выполненные. + +
+ +
+ +