v3.6.0: Улучшено модальное окно переноса задачи - нередактируемое поле с понятным форматированием даты
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 55s

This commit is contained in:
poignatov
2026-01-10 19:17:03 +03:00
parent 3d3fa13f41
commit cc7c6a905e
7 changed files with 767 additions and 92 deletions

View File

@@ -3791,6 +3791,7 @@ func main() {
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")
protected.HandleFunc("/api/tasks/{id}/complete-and-delete", app.completeAndDeleteTaskHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/tasks/{id}/postpone", app.postponeTaskHandler).Methods("POST", "OPTIONS")
// Admin operations
@@ -7843,6 +7844,314 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) {
})
}
// completeAndDeleteTaskHandler выполняет задачу и затем удаляет её
func (a *App) completeAndDeleteTaskHandler(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
}
// Сначала выполняем задачу (используем ту же логику, что и в completeTaskHandler)
// Создаем временный запрос для выполнения задачи
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 repetitionDate sql.NullString
var ownerID int
err = a.DB.QueryRow(`
SELECT id, name, reward_message, progression_base, repetition_period, repetition_date, user_id
FROM tasks
WHERE id = $1 AND deleted = FALSE
`, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &repetitionDate, &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
}
// Функция для замены плейсхолдеров в сообщении награды
replaceRewardPlaceholders := func(message string, rewardStrings map[int]string) string {
result := message
escapedMarkers := make(map[string]string)
for i := 0; i < 100; i++ {
escaped := fmt.Sprintf(`\$%d`, i)
marker := fmt.Sprintf(`__ESCAPED_DOLLAR_%d__`, i)
if strings.Contains(result, escaped) {
escapedMarkers[marker] = escaped
result = strings.ReplaceAll(result, escaped, marker)
}
}
for i := 0; i < 100; i++ {
placeholder := fmt.Sprintf("${%d}", i)
if rewardStr, ok := rewardStrings[i]; ok {
result = strings.ReplaceAll(result, placeholder, rewardStr)
}
}
for i := 99; i >= 0; i-- {
if rewardStr, ok := rewardStrings[i]; ok {
searchStr := fmt.Sprintf("$%d", i)
for {
idx := strings.LastIndex(result, searchStr)
if idx == -1 {
break
}
afterIdx := idx + len(searchStr)
if afterIdx >= len(result) || result[afterIdx] < '0' || result[afterIdx] > '9' {
result = result[:idx] + rewardStr + result[afterIdx:]
} else {
break
}
}
}
}
for marker, escaped := range escapedMarkers {
result = strings.ReplaceAll(result, marker, escaped)
}
return result
}
// Подставляем в reward_message основной задачи
var mainTaskMessage string
if task.RewardMessage != nil && *task.RewardMessage != "" {
mainTaskMessage = replaceRewardPlaceholders(*task.RewardMessage, rewardStrings)
} else {
mainTaskMessage = task.Name
}
// Получаем выбранные подзадачи
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
}
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()
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 {
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
}
subtaskMessage := replaceRewardPlaceholders(subtaskRewardMessage.String, subtaskRewardStrings)
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)
}
// Обновляем выбранные подзадачи
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)
}
}
// Помечаем задачу как удаленную
_, 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 completed and deleted successfully",
})
}
// postponeTaskHandler переносит задачу на указанную дату
func (a *App) postponeTaskHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {