diff --git a/VERSION b/VERSION index 4ec60bf..a7c176a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.19.13 +6.20.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 299560b..83f05f6 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -4798,6 +4798,7 @@ func main() { protected.HandleFunc("/api/tasks/{id}/postpone", app.postponeTaskHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/tasks/{id}/draft", app.saveTaskDraftHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/tasks/{id}/complete-at-end-of-day", app.completeTaskAtEndOfDayHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/tasks/{id}/copy", app.copyTaskHandler).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") @@ -10533,6 +10534,289 @@ func (a *App) deleteTaskHandler(w http.ResponseWriter, r *http.Request) { }) } +// copyTaskHandler копирует задачу (Тесты, Задачи, Закупки). Желания копировать нельзя. +func (a *App) copyTaskHandler(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 name string + var rewardMessage sql.NullString + var progressionBase sql.NullFloat64 + var repetitionPeriodStr string + var repetitionDateStr string + var wishlistID sql.NullInt64 + var configID sql.NullInt64 + var purchaseConfigID sql.NullInt64 + var groupName sql.NullString + var ownerID int + + err = a.DB.QueryRow(` + SELECT user_id, name, reward_message, progression_base, + CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END, + COALESCE(repetition_date, ''), + wishlist_id, config_id, purchase_config_id, group_name + FROM tasks + WHERE id = $1 AND deleted = FALSE + `, taskID).Scan(&ownerID, &name, &rewardMessage, &progressionBase, + &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID, &purchaseConfigID, &groupName) + + if err == sql.ErrNoRows || ownerID != userID { + sendErrorWithCORS(w, "Task not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting task for copy: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error getting task: %v", err), http.StatusInternalServerError) + return + } + + // Нельзя копировать задачи, привязанные к желанию + if wishlistID.Valid { + sendErrorWithCORS(w, "Cannot copy tasks linked to wishlist items", 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() + + // Получаем часовой пояс + timezoneStr := getEnv("TIMEZONE", "UTC") + loc, err := time.LoadLocation(timezoneStr) + if err != nil { + loc = time.UTC + } + now := time.Now().In(loc) + + // Создаём копию задачи + var newTaskID int + var repetitionPeriodValue interface{} + var repetitionDateValue interface{} + + if repetitionPeriodStr != "" { + repetitionPeriodValue = repetitionPeriodStr + } + if repetitionDateStr != "" { + repetitionDateValue = repetitionDateStr + } + + if repetitionPeriodValue != nil { + err = tx.QueryRow(` + INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, group_name) + VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, $6, 0, FALSE, $7) + RETURNING id + `, userID, name, rewardMessage, progressionBase, repetitionPeriodValue, now, groupName).Scan(&newTaskID) + } else if repetitionDateValue != nil { + nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDateStr, now) + if nextShowAt != nil { + err = tx.QueryRow(` + INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, group_name) + VALUES ($1, $2, $3, $4, NULL, $5, $6, 0, FALSE, $7) + RETURNING id + `, userID, name, rewardMessage, progressionBase, repetitionDateValue, nextShowAt, groupName).Scan(&newTaskID) + } else { + err = tx.QueryRow(` + INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, group_name) + VALUES ($1, $2, $3, $4, NULL, $5, 0, FALSE, $6) + RETURNING id + `, userID, name, rewardMessage, progressionBase, repetitionDateValue, groupName).Scan(&newTaskID) + } + } else { + err = tx.QueryRow(` + INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, group_name) + VALUES ($1, $2, $3, $4, NULL, NULL, $5, 0, FALSE, $6) + RETURNING id + `, userID, name, rewardMessage, progressionBase, now, groupName).Scan(&newTaskID) + } + + if err != nil { + log.Printf("Error creating task copy: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating task copy: %v", err), http.StatusInternalServerError) + return + } + + // Копируем награды основной задачи + _, err = tx.Exec(` + INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) + SELECT position, $1, project_id, value, use_progression + FROM reward_configs WHERE task_id = $2 + `, newTaskID, taskID) + if err != nil { + log.Printf("Error copying rewards: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error copying rewards: %v", err), http.StatusInternalServerError) + return + } + + // Если это тест — копируем конфигурацию и словари + if configID.Valid { + var wordsCount int + var maxCards sql.NullInt64 + err = a.DB.QueryRow(`SELECT words_count, max_cards FROM configs WHERE id = $1`, configID.Int64).Scan(&wordsCount, &maxCards) + if err != nil { + log.Printf("Error getting test config: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error getting test config: %v", err), http.StatusInternalServerError) + return + } + + var newConfigID int + if maxCards.Valid { + err = tx.QueryRow(`INSERT INTO configs (user_id, words_count, max_cards) VALUES ($1, $2, $3) RETURNING id`, + userID, wordsCount, maxCards.Int64).Scan(&newConfigID) + } else { + err = tx.QueryRow(`INSERT INTO configs (user_id, words_count) VALUES ($1, $2) RETURNING id`, + userID, wordsCount).Scan(&newConfigID) + } + if err != nil { + log.Printf("Error creating test config copy: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating test config copy: %v", err), http.StatusInternalServerError) + return + } + + _, err = tx.Exec(` + INSERT INTO config_dictionaries (config_id, dictionary_id) + SELECT $1, dictionary_id FROM config_dictionaries WHERE config_id = $2 + `, newConfigID, configID.Int64) + if err != nil { + log.Printf("Error copying config dictionaries: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error copying config dictionaries: %v", err), http.StatusInternalServerError) + return + } + + _, err = tx.Exec(`UPDATE tasks SET config_id = $1 WHERE id = $2`, newConfigID, newTaskID) + if err != nil { + log.Printf("Error linking config to copied task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error linking config: %v", err), http.StatusInternalServerError) + return + } + } + + // Если это закупка — копируем конфигурацию и доски + if purchaseConfigID.Valid { + var newPurchaseConfigID int + err = tx.QueryRow(`INSERT INTO purchase_configs (user_id) VALUES ($1) RETURNING id`, userID).Scan(&newPurchaseConfigID) + if err != nil { + log.Printf("Error creating purchase config copy: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating purchase config copy: %v", err), http.StatusInternalServerError) + return + } + + _, err = tx.Exec(` + INSERT INTO purchase_config_boards (purchase_config_id, board_id, group_name) + SELECT $1, board_id, group_name FROM purchase_config_boards WHERE purchase_config_id = $2 + `, newPurchaseConfigID, purchaseConfigID.Int64) + if err != nil { + log.Printf("Error copying purchase config boards: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error copying purchase config boards: %v", err), http.StatusInternalServerError) + return + } + + _, err = tx.Exec(`UPDATE tasks SET purchase_config_id = $1 WHERE id = $2`, newPurchaseConfigID, newTaskID) + if err != nil { + log.Printf("Error linking purchase config to copied task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error linking purchase config: %v", err), http.StatusInternalServerError) + return + } + } + + // Если это обычная задача — копируем подзадачи + if !configID.Valid && !purchaseConfigID.Valid { + subtaskRows, err := a.DB.Query(` + SELECT id, name, reward_message, progression_base, position + FROM tasks WHERE parent_task_id = $1 AND deleted = FALSE + ORDER BY COALESCE(position, id) + `, taskID) + if err != nil { + log.Printf("Error getting subtasks for copy: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error getting subtasks: %v", err), http.StatusInternalServerError) + return + } + defer subtaskRows.Close() + + for subtaskRows.Next() { + var origSubtaskID int + var subtaskName sql.NullString + var subtaskRewardMsg sql.NullString + var subtaskProgBase sql.NullFloat64 + var subtaskPosition sql.NullInt64 + + if err := subtaskRows.Scan(&origSubtaskID, &subtaskName, &subtaskRewardMsg, &subtaskProgBase, &subtaskPosition); err != nil { + log.Printf("Error scanning subtask: %v", err) + continue + } + + var newSubtaskID int + err = tx.QueryRow(` + 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, newTaskID, subtaskRewardMsg, subtaskProgBase, subtaskPosition).Scan(&newSubtaskID) + if err != nil { + log.Printf("Error copying subtask: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error copying subtask: %v", err), http.StatusInternalServerError) + return + } + + // Копируем награды подзадачи + _, err = tx.Exec(` + INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) + SELECT position, $1, project_id, value, use_progression + FROM reward_configs WHERE task_id = $2 + `, newSubtaskID, origSubtaskID) + if err != nil { + log.Printf("Error copying subtask rewards: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error copying subtask rewards: %v", err), http.StatusInternalServerError) + return + } + } + } + + // Коммитим транзакцию + if err := tx.Commit(); err != nil { + log.Printf("Error committing copy transaction: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) + return + } + + // Обновляем MV для групповых саджестов + if groupName.Valid && groupName.String != "" { + if err := a.refreshGroupSuggestionsMV(); err != nil { + log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) + } + } + + log.Printf("Task %d copied to new task %d by user %d", taskID, newTaskID, userID) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": newTaskID, + "success": true, + }) +} + // executeTask выполняет задачу (вынесенная логика) // Удаляет драфт перед выполнением и выполняет всю логику выполнения задачи func (a *App) executeTask(taskID int, userID int, req CompleteTaskRequest) error { diff --git a/play-life-web/package.json b/play-life-web/package.json index bdf5579..c7d82d6 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "6.19.13", + "version": "6.20.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/TaskForm.jsx b/play-life-web/src/components/TaskForm.jsx index c3a5900..2d79aa3 100644 --- a/play-life-web/src/components/TaskForm.jsx +++ b/play-life-web/src/components/TaskForm.jsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom' import { useAuth } from './auth/AuthContext' import Toast from './Toast' import SubmitButton from './SubmitButton' -import DeleteButton from './DeleteButton' +import './Wishlist.css' import './TaskForm.css' const API_URL = '/api/tasks' @@ -27,6 +27,9 @@ function TaskForm({ onNavigate, taskId, wishlistId, returnTo, returnWishlistId, const [toastMessage, setToastMessage] = useState(null) const [loadingTask, setLoadingTask] = useState(false) const [isDeleting, setIsDeleting] = useState(false) + const [isCopying, setIsCopying] = useState(false) + const [showActionMenu, setShowActionMenu] = useState(false) + const actionMenuHistoryRef = useRef(false) const [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи const [rewardPolicy, setRewardPolicy] = useState('general') // Политика награждения: 'personal' или 'general' @@ -859,11 +862,43 @@ function TaskForm({ onNavigate, taskId, wishlistId, returnTo, returnWishlistId, } } + const openActionMenu = () => { + setShowActionMenu(true) + window.history.pushState({ actionMenu: true }, '') + actionMenuHistoryRef.current = true + } + + const closeActionMenu = () => { + setShowActionMenu(false) + if (actionMenuHistoryRef.current) { + actionMenuHistoryRef.current = false + window.history.back() + } + } + + // Обработка popstate для закрытия action menu кнопкой назад + useEffect(() => { + const handlePopState = (e) => { + if (showActionMenu) { + actionMenuHistoryRef.current = false + setShowActionMenu(false) + } + } + window.addEventListener('popstate', handlePopState) + return () => window.removeEventListener('popstate', handlePopState) + }, [showActionMenu]) + const handleDelete = async () => { if (!taskId) return - if (!window.confirm(`Вы уверены, что хотите удалить задачу "${name}"?`)) { - return + setShowActionMenu(false) + // Убираем запись action menu из истории + закрываем экран редактирования + if (actionMenuHistoryRef.current) { + actionMenuHistoryRef.current = false + // go(-2): action menu + task form + window.history.go(-2) + } else { + window.history.back() } setIsDeleting(true) @@ -875,13 +910,35 @@ function TaskForm({ onNavigate, taskId, wishlistId, returnTo, returnWishlistId, if (!response.ok) { throw new Error('Ошибка при удалении задачи') } - - // Возвращаемся к списку задач - onNavigate?.('tasks') } catch (err) { console.error('Error deleting task:', err) - setToastMessage({ text: err.message || 'Ошибка при удалении задачи', type: 'error' }) - setIsDeleting(false) + } + } + + const handleCopy = async () => { + if (!taskId) return + + setShowActionMenu(false) + // Убираем запись action menu из истории + закрываем экран редактирования + if (actionMenuHistoryRef.current) { + actionMenuHistoryRef.current = false + window.history.go(-2) + } else { + window.history.back() + } + + setIsCopying(true) + try { + const response = await authFetch(`${API_URL}/${taskId}/copy`, { + method: 'POST', + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + throw new Error(errorText || 'Ошибка при копировании задачи') + } + } catch (err) { + console.error('Error copying task:', err) } } @@ -1443,16 +1500,57 @@ function TaskForm({ onNavigate, taskId, wishlistId, returnTo, returnWishlistId, {loading ? 'Сохранение...' : 'Сохранить'} {taskId && ( - + )} , document.body ) : null} + {showActionMenu && createPortal( +
+
e.stopPropagation()}> +
+

{name}

+
+
+ {!currentWishlistId && ( + + )} + +
+
+
, + document.body + )} ) }