v3.6.0: Улучшено модальное окно переноса задачи - нередактируемое поле с понятным форматированием даты
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 55s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 55s
This commit is contained in:
@@ -3791,6 +3791,7 @@ func main() {
|
|||||||
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")
|
||||||
protected.HandleFunc("/api/tasks/{id}/complete", app.completeTaskHandler).Methods("POST", "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")
|
protected.HandleFunc("/api/tasks/{id}/postpone", app.postponeTaskHandler).Methods("POST", "OPTIONS")
|
||||||
|
|
||||||
// Admin operations
|
// 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 переносит задачу на указанную дату
|
// postponeTaskHandler переносит задачу на указанную дату
|
||||||
func (a *App) postponeTaskHandler(w http.ResponseWriter, r *http.Request) {
|
func (a *App) postponeTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == "OPTIONS" {
|
if r.Method == "OPTIONS" {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "3.5.7",
|
"version": "3.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -129,22 +129,25 @@
|
|||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-complete-section {
|
.progression-section {
|
||||||
margin-top: 1rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progression-input-group {
|
.progression-label {
|
||||||
display: flex;
|
display: block;
|
||||||
gap: 0.5rem;
|
font-size: 0.875rem;
|
||||||
align-items: center;
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progression-input {
|
.progression-input {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progression-input:focus {
|
.progression-input:focus {
|
||||||
@@ -153,7 +156,46 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-detail-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-message-preview {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border-left: 3px solid #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-message-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-message-text {
|
||||||
|
color: #1f2937;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-message-text strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.complete-button {
|
.complete-button {
|
||||||
|
flex: 1;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background: linear-gradient(to right, #6366f1, #8b5cf6);
|
background: linear-gradient(to right, #6366f1, #8b5cf6);
|
||||||
color: white;
|
color: white;
|
||||||
@@ -163,11 +205,9 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
min-width: 3rem;
|
display: flex;
|
||||||
}
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
.complete-button.full-width {
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.complete-button:hover:not(:disabled) {
|
.complete-button:hover:not(:disabled) {
|
||||||
@@ -180,6 +220,34 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.close-button-outline {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #6366f1;
|
||||||
|
border: 2px solid #6366f1;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button-outline:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button-outline:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.loading,
|
.loading,
|
||||||
.error-message {
|
.error-message {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1,9 +1,168 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useAuth } from './auth/AuthContext'
|
import { useAuth } from './auth/AuthContext'
|
||||||
import './TaskDetail.css'
|
import './TaskDetail.css'
|
||||||
|
|
||||||
const API_URL = '/api/tasks'
|
const API_URL = '/api/tasks'
|
||||||
|
|
||||||
|
// Функция для проверки, является ли период нулевым
|
||||||
|
const isZeroPeriod = (intervalStr) => {
|
||||||
|
if (!intervalStr) return false
|
||||||
|
const trimmed = intervalStr.trim()
|
||||||
|
const parts = trimmed.split(/\s+/)
|
||||||
|
if (parts.length < 1) return false
|
||||||
|
const value = parseInt(parts[0], 10)
|
||||||
|
return !isNaN(value) && value === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для проверки, является ли repetition_date нулевым
|
||||||
|
const isZeroDate = (dateStr) => {
|
||||||
|
if (!dateStr) return false
|
||||||
|
const trimmed = dateStr.trim()
|
||||||
|
const parts = trimmed.split(/\s+/)
|
||||||
|
if (parts.length < 2) return false
|
||||||
|
const value = parts[0]
|
||||||
|
const numValue = parseInt(value, 10)
|
||||||
|
return !isNaN(numValue) && numValue === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для форматирования числа как %.4g в Go (до 4 значащих цифр)
|
||||||
|
const formatScore = (num) => {
|
||||||
|
if (num === 0) return '0'
|
||||||
|
|
||||||
|
// Используем toPrecision(4) для получения до 4 значащих цифр
|
||||||
|
let str = num.toPrecision(4)
|
||||||
|
|
||||||
|
// Убираем лишние нули в конце (но оставляем точку если есть цифры после неё)
|
||||||
|
str = str.replace(/\.?0+$/, '')
|
||||||
|
|
||||||
|
// Если получилась экспоненциальная нотация для больших чисел, конвертируем обратно
|
||||||
|
if (str.includes('e+') || str.includes('e-')) {
|
||||||
|
const numValue = parseFloat(str)
|
||||||
|
// Для чисел >= 10000 используем экспоненциальную нотацию
|
||||||
|
if (Math.abs(numValue) >= 10000) {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
// Для остальных конвертируем в обычное число
|
||||||
|
return numValue.toString().replace(/\.?0+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для формирования сообщения Telegram в реальном времени
|
||||||
|
const formatTelegramMessage = (task, rewards, subtasks, selectedSubtasks, progressionValue) => {
|
||||||
|
if (!task) return ''
|
||||||
|
|
||||||
|
// Вычисляем score для каждой награды основной задачи
|
||||||
|
const rewardStrings = {}
|
||||||
|
const progressionBase = task.progression_base
|
||||||
|
const hasProgression = progressionBase != null
|
||||||
|
// Если прогрессия не введена - используем progression_base
|
||||||
|
const value = progressionValue && progressionValue.trim() !== ''
|
||||||
|
? parseFloat(progressionValue)
|
||||||
|
: (hasProgression ? progressionBase : null)
|
||||||
|
|
||||||
|
rewards.forEach(reward => {
|
||||||
|
let score = reward.value
|
||||||
|
if (reward.use_progression && hasProgression) {
|
||||||
|
if (value !== null && !isNaN(value)) {
|
||||||
|
score = (value / progressionBase) * reward.value
|
||||||
|
} else {
|
||||||
|
// Если прогрессия не введена, используем progression_base (score = reward.value)
|
||||||
|
score = reward.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreStr = score >= 0
|
||||||
|
? `**${reward.project_name}+${formatScore(score)}**`
|
||||||
|
: `**${reward.project_name}-${formatScore(Math.abs(score))}**`
|
||||||
|
rewardStrings[reward.position] = scoreStr
|
||||||
|
})
|
||||||
|
|
||||||
|
// Функция для замены плейсхолдеров
|
||||||
|
const replacePlaceholders = (message, rewardStrings) => {
|
||||||
|
let result = message
|
||||||
|
// Сначала защищаем экранированные плейсхолдеры
|
||||||
|
const escapedMarkers = {}
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const escaped = `\\$${i}`
|
||||||
|
const marker = `__ESCAPED_DOLLAR_${i}__`
|
||||||
|
if (result.includes(escaped)) {
|
||||||
|
escapedMarkers[marker] = escaped
|
||||||
|
result = result.replace(new RegExp(escaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Заменяем ${0}, ${1}, и т.д.
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const placeholder = `\${${i}}`
|
||||||
|
if (rewardStrings[i]) {
|
||||||
|
result = result.replace(new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), rewardStrings[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Заменяем $0, $1, и т.д. (с конца, чтобы не заменить $1 в $10)
|
||||||
|
for (let i = 99; i >= 0; i--) {
|
||||||
|
if (rewardStrings[i]) {
|
||||||
|
const searchStr = `$${i}`
|
||||||
|
const regex = new RegExp(`\\$${i}(?!\\d)`, 'g')
|
||||||
|
result = result.replace(regex, rewardStrings[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Восстанавливаем экранированные
|
||||||
|
Object.entries(escapedMarkers).forEach(([marker, escaped]) => {
|
||||||
|
result = result.replace(new RegExp(marker, 'g'), escaped)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем сообщение основной задачи
|
||||||
|
let mainTaskMessage = task.reward_message && task.reward_message.trim() !== ''
|
||||||
|
? replacePlaceholders(task.reward_message, rewardStrings)
|
||||||
|
: task.name
|
||||||
|
|
||||||
|
// Формируем сообщения подзадач
|
||||||
|
const subtaskMessages = []
|
||||||
|
subtasks.forEach(subtask => {
|
||||||
|
if (!selectedSubtasks.has(subtask.task.id)) return
|
||||||
|
if (!subtask.task.reward_message || subtask.task.reward_message.trim() === '') return
|
||||||
|
|
||||||
|
// Вычисляем score для наград подзадачи
|
||||||
|
const subtaskRewardStrings = {}
|
||||||
|
subtask.rewards.forEach(reward => {
|
||||||
|
let score = reward.value
|
||||||
|
const subtaskProgressionBase = subtask.task.progression_base
|
||||||
|
if (reward.use_progression) {
|
||||||
|
if (subtaskProgressionBase != null && value !== null && !isNaN(value)) {
|
||||||
|
score = (value / subtaskProgressionBase) * reward.value
|
||||||
|
} else if (hasProgression && value !== null && !isNaN(value)) {
|
||||||
|
score = (value / progressionBase) * reward.value
|
||||||
|
} else if (subtaskProgressionBase != null) {
|
||||||
|
// Если прогрессия не введена, используем progression_base подзадачи (score = reward.value)
|
||||||
|
score = reward.value
|
||||||
|
} else if (hasProgression) {
|
||||||
|
// Если у подзадачи нет progression_base, используем основной (score = reward.value)
|
||||||
|
score = reward.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreStr = score >= 0
|
||||||
|
? `**${reward.project_name}+${formatScore(score)}**`
|
||||||
|
: `**${reward.project_name}-${formatScore(Math.abs(score))}**`
|
||||||
|
subtaskRewardStrings[reward.position] = scoreStr
|
||||||
|
})
|
||||||
|
|
||||||
|
const subtaskMessage = replacePlaceholders(subtask.task.reward_message, subtaskRewardStrings)
|
||||||
|
subtaskMessages.push(subtaskMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Формируем итоговое сообщение
|
||||||
|
let finalMessage = mainTaskMessage
|
||||||
|
subtaskMessages.forEach(subtaskMsg => {
|
||||||
|
finalMessage += '\n + ' + subtaskMsg
|
||||||
|
})
|
||||||
|
|
||||||
|
return finalMessage
|
||||||
|
}
|
||||||
|
|
||||||
function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [taskDetail, setTaskDetail] = useState(null)
|
const [taskDetail, setTaskDetail] = useState(null)
|
||||||
@@ -56,14 +215,10 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleComplete = async () => {
|
const handleComplete = async (shouldDelete = false) => {
|
||||||
if (!taskDetail) return
|
if (!taskDetail) return
|
||||||
|
|
||||||
// Валидация: если progression_base != null, то value обязателен
|
// Если прогрессия не введена, используем 0 (валидация не требуется)
|
||||||
if (taskDetail.task.progression_base != null && !progressionValue.trim()) {
|
|
||||||
alert('Поле "Значение" обязательно для задач с прогрессией')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCompleting(true)
|
setIsCompleting(true)
|
||||||
try {
|
try {
|
||||||
@@ -71,14 +226,25 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
|||||||
children_task_ids: Array.from(selectedSubtasks)
|
children_task_ids: Array.from(selectedSubtasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (taskDetail.task.progression_base != null && progressionValue.trim()) {
|
// Если есть прогрессия, отправляем значение (или progression_base, если не введено)
|
||||||
|
if (taskDetail.task.progression_base != null) {
|
||||||
|
if (progressionValue.trim()) {
|
||||||
payload.value = parseFloat(progressionValue)
|
payload.value = parseFloat(progressionValue)
|
||||||
if (isNaN(payload.value)) {
|
if (isNaN(payload.value)) {
|
||||||
throw new Error('Неверное значение')
|
throw new Error('Неверное значение')
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Если прогрессия не введена - используем progression_base
|
||||||
|
payload.value = taskDetail.task.progression_base
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await authFetch(`${API_URL}/${taskId}/complete`, {
|
// Используем единую ручку для выполнения и удаления
|
||||||
|
const endpoint = shouldDelete
|
||||||
|
? `${API_URL}/${taskId}/complete-and-delete`
|
||||||
|
: `${API_URL}/${taskId}/complete`
|
||||||
|
|
||||||
|
const response = await authFetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -115,7 +281,23 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
|||||||
|
|
||||||
const { task, rewards, subtasks } = taskDetail || {}
|
const { task, rewards, subtasks } = taskDetail || {}
|
||||||
const hasProgression = task?.progression_base != null
|
const hasProgression = task?.progression_base != null
|
||||||
const canComplete = !hasProgression || (hasProgression && progressionValue.trim())
|
// Кнопка всегда активна (если прогрессия не введена, используем 0)
|
||||||
|
const canComplete = true
|
||||||
|
|
||||||
|
// Определяем, является ли задача одноразовой
|
||||||
|
// Одноразовая задача: когда оба поля null/undefined (из бэкенда видно, что в этом случае задача помечается как deleted)
|
||||||
|
// Бесконечная задача: когда хотя бы одно поле равно "0 day" или "0 week" и т.д.
|
||||||
|
// Повторяющаяся задача: когда есть значение (не null и не 0)
|
||||||
|
// Кнопка "Закрыть" показывается для задач, которые НЕ одноразовые (имеют повторение, даже если оно равно 0)
|
||||||
|
// Проверяем, что оба поля отсутствуют (null или undefined)
|
||||||
|
const isOneTime = (task?.repetition_period == null || task?.repetition_period === undefined) &&
|
||||||
|
(task?.repetition_date == null || task?.repetition_date === undefined)
|
||||||
|
|
||||||
|
// Формируем сообщение для Telegram в реальном времени
|
||||||
|
const telegramMessage = useMemo(() => {
|
||||||
|
if (!taskDetail) return ''
|
||||||
|
return formatTelegramMessage(task, rewards || [], subtasks || [], selectedSubtasks, progressionValue)
|
||||||
|
}, [taskDetail, task, rewards, subtasks, selectedSubtasks, progressionValue])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="task-detail-modal-overlay" onClick={onClose}>
|
<div className="task-detail-modal-overlay" onClick={onClose}>
|
||||||
@@ -140,6 +322,22 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
|||||||
|
|
||||||
{!loading && !error && taskDetail && (
|
{!loading && !error && taskDetail && (
|
||||||
<>
|
<>
|
||||||
|
{/* Поле ввода прогрессии */}
|
||||||
|
{hasProgression && (
|
||||||
|
<div className="progression-section">
|
||||||
|
<label className="progression-label">Значение прогрессии</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={progressionValue}
|
||||||
|
onChange={(e) => setProgressionValue(e.target.value)}
|
||||||
|
placeholder={task.progression_base?.toString() || ''}
|
||||||
|
className="progression-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Список подзадач */}
|
||||||
{subtasks && subtasks.length > 0 && (
|
{subtasks && subtasks.length > 0 && (
|
||||||
<div className="task-subtasks">
|
<div className="task-subtasks">
|
||||||
{subtasks.map((subtask) => {
|
{subtasks.map((subtask) => {
|
||||||
@@ -163,35 +361,47 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="task-complete-section">
|
{/* Разделитель - показываем только если есть прогрессия или подзадачи */}
|
||||||
{hasProgression ? (
|
{(hasProgression || (subtasks && subtasks.length > 0)) && (
|
||||||
<div className="progression-input-group">
|
<div className="task-detail-divider"></div>
|
||||||
<input
|
)}
|
||||||
type="number"
|
|
||||||
step="any"
|
{/* Сообщение награды - показываем только если есть прогрессия или подзадачи */}
|
||||||
value={progressionValue}
|
{(hasProgression || (subtasks && subtasks.length > 0)) && (
|
||||||
onChange={(e) => setProgressionValue(e.target.value)}
|
<div className="telegram-message-preview">
|
||||||
placeholder={`Значение (~${task.progression_base})`}
|
<div className="telegram-message-label">Сообщение награды:</div>
|
||||||
className="progression-input"
|
<div className="telegram-message-text" dangerouslySetInnerHTML={{
|
||||||
/>
|
__html: telegramMessage
|
||||||
{progressionValue.trim() && (
|
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\n/g, '<br>')
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопки действий */}
|
||||||
|
<div className="task-actions-section">
|
||||||
<button
|
<button
|
||||||
onClick={handleComplete}
|
onClick={() => handleComplete(false)}
|
||||||
disabled={isCompleting}
|
disabled={isCompleting || !canComplete}
|
||||||
className="complete-button"
|
className="complete-button"
|
||||||
>
|
>
|
||||||
✓
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginRight: '0.5rem' }}>
|
||||||
</button>
|
<path d="M13.5 4L6 11.5L2.5 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
)}
|
</svg>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={handleComplete}
|
|
||||||
disabled={isCompleting || !canComplete}
|
|
||||||
className="complete-button full-width"
|
|
||||||
>
|
|
||||||
{isCompleting ? 'Выполнение...' : 'Выполнить'}
|
{isCompleting ? 'Выполнение...' : 'Выполнить'}
|
||||||
</button>
|
</button>
|
||||||
|
{!isOneTime && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleComplete(true)}
|
||||||
|
disabled={isCompleting || !canComplete}
|
||||||
|
className="close-button-outline"
|
||||||
|
title="Выполнить и закрыть"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 7L7 11L15 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M3 11L7 15L15 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -149,6 +149,11 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-onetime-icon {
|
||||||
|
color: #9ca3af;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.task-actions {
|
.task-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -272,21 +277,37 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-postpone-input {
|
.task-postpone-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-display-date {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
color: #1f2937;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-postpone-input:focus {
|
.task-postpone-display-date:hover {
|
||||||
outline: none;
|
|
||||||
border-color: #6366f1;
|
border-color: #6366f1;
|
||||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-display-date:active {
|
||||||
|
background: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-postpone-submit-checkmark {
|
.task-postpone-submit-checkmark {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
|||||||
const [postponeDate, setPostponeDate] = useState('')
|
const [postponeDate, setPostponeDate] = useState('')
|
||||||
const [isPostponing, setIsPostponing] = useState(false)
|
const [isPostponing, setIsPostponing] = useState(false)
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
|
const dateInputRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -34,43 +35,8 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
|||||||
const handleCheckmarkClick = async (task, e) => {
|
const handleCheckmarkClick = async (task, e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
const hasProgression = task.has_progression || task.progression_base != null
|
// Всегда открываем диалог подтверждения
|
||||||
const hasSubtasks = task.subtasks_count > 0
|
|
||||||
|
|
||||||
if (hasProgression || hasSubtasks) {
|
|
||||||
// Открываем экран details
|
|
||||||
setSelectedTaskForDetail(task.id)
|
setSelectedTaskForDetail(task.id)
|
||||||
} else {
|
|
||||||
// Отправляем задачу
|
|
||||||
setIsCompleting(true)
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${API_URL}/${task.id}/complete`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}))
|
|
||||||
throw new Error(errorData.message || 'Ошибка при выполнении задачи')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Показываем toast о выполнении задачи
|
|
||||||
setToast({ message: 'Задача выполнена' })
|
|
||||||
|
|
||||||
// Обновляем список
|
|
||||||
if (onRefresh) {
|
|
||||||
onRefresh()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error completing task:', err)
|
|
||||||
alert(err.message || 'Ошибка при выполнении задачи')
|
|
||||||
} finally {
|
|
||||||
setIsCompleting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCloseDetail = () => {
|
const handleCloseDetail = () => {
|
||||||
@@ -210,6 +176,67 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
|||||||
return `${year}-${month}-${day}`
|
return `${year}-${month}-${day}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Форматирование даты для отображения с понятными названиями
|
||||||
|
const formatDateForDisplay = (dateStr) => {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
|
||||||
|
// Парсим дату из формата YYYY-MM-DD
|
||||||
|
const dateParts = dateStr.split('-')
|
||||||
|
if (dateParts.length !== 3) return dateStr
|
||||||
|
|
||||||
|
const yearNum = parseInt(dateParts[0], 10)
|
||||||
|
const monthNum = parseInt(dateParts[1], 10) - 1 // месяцы в JS начинаются с 0
|
||||||
|
const dayNum = parseInt(dateParts[2], 10)
|
||||||
|
|
||||||
|
if (isNaN(yearNum) || isNaN(monthNum) || isNaN(dayNum)) return dateStr
|
||||||
|
|
||||||
|
const targetDate = new Date(yearNum, monthNum, dayNum)
|
||||||
|
targetDate.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
now.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const diffDays = Math.floor((targetDate - now) / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
// Сегодня
|
||||||
|
if (diffDays === 0) {
|
||||||
|
return 'Сегодня'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Завтра
|
||||||
|
if (diffDays === 1) {
|
||||||
|
return 'Завтра'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вчера
|
||||||
|
if (diffDays === -1) {
|
||||||
|
return 'Вчера'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дни недели для ближайших дней из будущего (в пределах 7 дней)
|
||||||
|
if (diffDays > 0 && diffDays <= 7) {
|
||||||
|
const dayNames = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота']
|
||||||
|
const dayOfWeek = targetDate.getDay()
|
||||||
|
return dayNames[dayOfWeek]
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthNames = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||||
|
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
|
||||||
|
|
||||||
|
// Если это число из того же года - только день и месяц
|
||||||
|
if (targetDate.getFullYear() === now.getFullYear()) {
|
||||||
|
const displayDay = targetDate.getDate()
|
||||||
|
const displayMonth = monthNames[targetDate.getMonth()]
|
||||||
|
return `${displayDay} ${displayMonth}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для других случаев - полная дата
|
||||||
|
const displayDay = targetDate.getDate()
|
||||||
|
const displayMonth = monthNames[targetDate.getMonth()]
|
||||||
|
const displayYear = targetDate.getFullYear()
|
||||||
|
return `${displayDay} ${displayMonth} ${displayYear}`
|
||||||
|
}
|
||||||
|
|
||||||
const handlePostponeClick = (task, e) => {
|
const handlePostponeClick = (task, e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setSelectedTaskForPostpone(task)
|
setSelectedTaskForPostpone(task)
|
||||||
@@ -436,6 +463,10 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
|||||||
// Не проверяем next_show_at, так как для бесконечных задач он может быть установлен при выполнении
|
// Не проверяем next_show_at, так как для бесконечных задач он может быть установлен при выполнении
|
||||||
const isInfinite = (hasZeroPeriod && hasZeroDate) || (hasZeroPeriod && !task.repetition_date)
|
const isInfinite = (hasZeroPeriod && hasZeroDate) || (hasZeroPeriod && !task.repetition_date)
|
||||||
|
|
||||||
|
// Одноразовая задача: когда оба поля null/undefined
|
||||||
|
const isOneTime = (task.repetition_period == null || task.repetition_period === undefined) &&
|
||||||
|
(task.repetition_date == null || task.repetition_date === undefined)
|
||||||
|
|
||||||
// Отладка для задачи "Ролик"
|
// Отладка для задачи "Ролик"
|
||||||
if (task.name === 'Ролик') {
|
if (task.name === 'Ролик') {
|
||||||
console.log('Task "Ролик":', {
|
console.log('Task "Ролик":', {
|
||||||
@@ -508,6 +539,24 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
|||||||
<path d="M12 12c0 2.5 1.5 4.5 3.5 4.5S19 14.5 19 12s-1.5-4.5-3.5-4.5S12 9.5 12 12z"/>
|
<path d="M12 12c0 2.5 1.5 4.5 3.5 4.5S19 14.5 19 12s-1.5-4.5-3.5-4.5S12 9.5 12 12z"/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
{isOneTime && (
|
||||||
|
<svg
|
||||||
|
className="task-onetime-icon"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
title="Одноразовая задача"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="14"></line>
|
||||||
|
<circle cx="12" cy="18" r="1"></circle>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Показываем дату только для выполненных задач */}
|
{/* Показываем дату только для выполненных задач */}
|
||||||
@@ -683,12 +732,30 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
|||||||
<div className="task-postpone-modal-content">
|
<div className="task-postpone-modal-content">
|
||||||
<div className="task-postpone-input-group">
|
<div className="task-postpone-input-group">
|
||||||
<input
|
<input
|
||||||
|
ref={dateInputRef}
|
||||||
type="date"
|
type="date"
|
||||||
value={postponeDate}
|
value={postponeDate}
|
||||||
onChange={(e) => setPostponeDate(e.target.value)}
|
onChange={(e) => setPostponeDate(e.target.value)}
|
||||||
className="task-postpone-input"
|
className="task-postpone-input"
|
||||||
min={new Date().toISOString().split('T')[0]}
|
min={new Date().toISOString().split('T')[0]}
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
className="task-postpone-display-date"
|
||||||
|
onClick={() => {
|
||||||
|
// Открываем календарь при клике
|
||||||
|
if (dateInputRef.current) {
|
||||||
|
if (typeof dateInputRef.current.showPicker === 'function') {
|
||||||
|
dateInputRef.current.showPicker()
|
||||||
|
} else {
|
||||||
|
// Fallback для браузеров без showPicker
|
||||||
|
dateInputRef.current.focus()
|
||||||
|
dateInputRef.current.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{postponeDate ? formatDateForDisplay(postponeDate) : 'Выберите дату'}
|
||||||
|
</div>
|
||||||
{postponeDate && (
|
{postponeDate && (
|
||||||
<button
|
<button
|
||||||
onClick={handlePostponeSubmit}
|
onClick={handlePostponeSubmit}
|
||||||
|
|||||||
Reference in New Issue
Block a user