6.20.0: Копирование задач и меню действий
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
poignatov
2026-03-18 21:39:15 +03:00
parent b82db8d80f
commit eb68eca63f
4 changed files with 398 additions and 16 deletions

View File

@@ -1 +1 @@
6.19.13 6.20.0

View File

@@ -4798,6 +4798,7 @@ func main() {
protected.HandleFunc("/api/tasks/{id}/postpone", app.postponeTaskHandler).Methods("POST", "OPTIONS") 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}/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}/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.getTaskDetailHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/tasks/{id}", app.updateTaskHandler).Methods("PUT", "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}", 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 выполняет задачу (вынесенная логика) // executeTask выполняет задачу (вынесенная логика)
// Удаляет драфт перед выполнением и выполняет всю логику выполнения задачи // Удаляет драфт перед выполнением и выполняет всю логику выполнения задачи
func (a *App) executeTask(taskID int, userID int, req CompleteTaskRequest) error { func (a *App) executeTask(taskID int, userID int, req CompleteTaskRequest) error {

View File

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

View File

@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext' import { useAuth } from './auth/AuthContext'
import Toast from './Toast' import Toast from './Toast'
import SubmitButton from './SubmitButton' import SubmitButton from './SubmitButton'
import DeleteButton from './DeleteButton' import './Wishlist.css'
import './TaskForm.css' import './TaskForm.css'
const API_URL = '/api/tasks' const API_URL = '/api/tasks'
@@ -27,6 +27,9 @@ function TaskForm({ onNavigate, taskId, wishlistId, returnTo, returnWishlistId,
const [toastMessage, setToastMessage] = useState(null) const [toastMessage, setToastMessage] = useState(null)
const [loadingTask, setLoadingTask] = useState(false) const [loadingTask, setLoadingTask] = useState(false)
const [isDeleting, setIsDeleting] = 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 [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании
const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи
const [rewardPolicy, setRewardPolicy] = useState('general') // Политика награждения: 'personal' или 'general' 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 () => { const handleDelete = async () => {
if (!taskId) return if (!taskId) return
if (!window.confirm(`Вы уверены, что хотите удалить задачу "${name}"?`)) { setShowActionMenu(false)
return // Убираем запись action menu из истории + закрываем экран редактирования
if (actionMenuHistoryRef.current) {
actionMenuHistoryRef.current = false
// go(-2): action menu + task form
window.history.go(-2)
} else {
window.history.back()
} }
setIsDeleting(true) setIsDeleting(true)
@@ -875,13 +910,35 @@ function TaskForm({ onNavigate, taskId, wishlistId, returnTo, returnWishlistId,
if (!response.ok) { if (!response.ok) {
throw new Error('Ошибка при удалении задачи') throw new Error('Ошибка при удалении задачи')
} }
// Возвращаемся к списку задач
onNavigate?.('tasks')
} catch (err) { } catch (err) {
console.error('Error deleting task:', 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 ? 'Сохранение...' : 'Сохранить'} {loading ? 'Сохранение...' : 'Сохранить'}
</button> </button>
{taskId && ( {taskId && (
<DeleteButton <button
onClick={handleDelete} type="button"
loading={isDeleting} onClick={openActionMenu}
disabled={loading} disabled={loading || isDeleting || isCopying}
title="Удалить задачу" style={{
/> width: '52px',
height: '52px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
color: '#059669',
border: '2px solid #059669',
borderRadius: '0.5rem',
fontSize: '1.25rem',
fontWeight: 700,
cursor: (loading || isDeleting || isCopying) ? 'not-allowed' : 'pointer',
lineHeight: 1,
flexShrink: 0,
padding: 0,
boxSizing: 'border-box',
transition: 'all 0.2s',
}}
title="Действия"
>
</button>
)} )}
</div>, </div>,
document.body document.body
) : null} ) : null}
{showActionMenu && createPortal(
<div className="wishlist-modal-overlay" style={{ zIndex: 2000 }} onClick={closeActionMenu}>
<div className="wishlist-modal" onClick={(e) => e.stopPropagation()}>
<div className="wishlist-modal-header">
<h3>{name}</h3>
</div>
<div className="wishlist-modal-actions">
{!currentWishlistId && (
<button className="wishlist-modal-copy" onClick={handleCopy}>
Копировать
</button>
)}
<button className="wishlist-modal-delete" onClick={handleDelete}>
Удалить
</button>
</div>
</div>
</div>,
document.body
)}
</> </>
) )
} }