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 && (
-