feat: добавлена функциональность откладывания задач (next_show_at)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 41s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 41s
This commit is contained in:
@@ -205,6 +205,7 @@ type Task struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Completed int `json:"completed"`
|
Completed int `json:"completed"`
|
||||||
LastCompletedAt *string `json:"last_completed_at,omitempty"`
|
LastCompletedAt *string `json:"last_completed_at,omitempty"`
|
||||||
|
NextShowAt *string `json:"next_show_at,omitempty"`
|
||||||
RewardMessage *string `json:"reward_message,omitempty"`
|
RewardMessage *string `json:"reward_message,omitempty"`
|
||||||
ProgressionBase *float64 `json:"progression_base,omitempty"`
|
ProgressionBase *float64 `json:"progression_base,omitempty"`
|
||||||
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
||||||
@@ -261,6 +262,10 @@ type CompleteTaskRequest struct {
|
|||||||
ChildrenTaskIDs []int `json:"children_task_ids,omitempty"`
|
ChildrenTaskIDs []int `json:"children_task_ids,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PostponeTaskRequest struct {
|
||||||
|
NextShowAt *string `json:"next_show_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Auth types
|
// Auth types
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -2916,6 +2921,11 @@ func (a *App) initPlayLifeDB() error {
|
|||||||
log.Printf("Warning: Failed to apply migration 016 (add repetition_period): %v", err)
|
log.Printf("Warning: Failed to apply migration 016 (add repetition_period): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply migration 017: Add next_show_at to tasks
|
||||||
|
if _, err := a.DB.Exec("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS next_show_at TIMESTAMP WITH TIME ZONE"); err != nil {
|
||||||
|
log.Printf("Warning: Failed to apply migration 017 (add next_show_at): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Создаем таблицу reward_configs
|
// Создаем таблицу reward_configs
|
||||||
createRewardConfigsTable := `
|
createRewardConfigsTable := `
|
||||||
CREATE TABLE IF NOT EXISTS reward_configs (
|
CREATE TABLE IF NOT EXISTS reward_configs (
|
||||||
@@ -3598,6 +3608,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}/postpone", app.postponeTaskHandler).Methods("POST", "OPTIONS")
|
||||||
|
|
||||||
// Admin operations
|
// Admin operations
|
||||||
protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
|
protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
|
||||||
@@ -6260,6 +6271,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
t.name,
|
t.name,
|
||||||
t.completed,
|
t.completed,
|
||||||
t.last_completed_at,
|
t.last_completed_at,
|
||||||
|
t.next_show_at,
|
||||||
t.repetition_period::text,
|
t.repetition_period::text,
|
||||||
t.progression_base,
|
t.progression_base,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
@@ -6301,6 +6313,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var task Task
|
var task Task
|
||||||
var lastCompletedAt sql.NullString
|
var lastCompletedAt sql.NullString
|
||||||
|
var nextShowAt sql.NullString
|
||||||
var repetitionPeriod sql.NullString
|
var repetitionPeriod sql.NullString
|
||||||
var progressionBase sql.NullFloat64
|
var progressionBase sql.NullFloat64
|
||||||
var projectNames pq.StringArray
|
var projectNames pq.StringArray
|
||||||
@@ -6310,7 +6323,8 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
&task.ID,
|
&task.ID,
|
||||||
&task.Name,
|
&task.Name,
|
||||||
&task.Completed,
|
&task.Completed,
|
||||||
&lastCompletedAt,
|
&lastCompletedAt,
|
||||||
|
&nextShowAt,
|
||||||
&repetitionPeriod,
|
&repetitionPeriod,
|
||||||
&progressionBase,
|
&progressionBase,
|
||||||
&task.SubtasksCount,
|
&task.SubtasksCount,
|
||||||
@@ -6325,6 +6339,9 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
if lastCompletedAt.Valid {
|
if lastCompletedAt.Valid {
|
||||||
task.LastCompletedAt = &lastCompletedAt.String
|
task.LastCompletedAt = &lastCompletedAt.String
|
||||||
}
|
}
|
||||||
|
if nextShowAt.Valid {
|
||||||
|
task.NextShowAt = &nextShowAt.String
|
||||||
|
}
|
||||||
if repetitionPeriod.Valid {
|
if repetitionPeriod.Valid {
|
||||||
task.RepetitionPeriod = &repetitionPeriod.String
|
task.RepetitionPeriod = &repetitionPeriod.String
|
||||||
}
|
}
|
||||||
@@ -6387,17 +6404,18 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var rewardMessage sql.NullString
|
var rewardMessage sql.NullString
|
||||||
var progressionBase sql.NullFloat64
|
var progressionBase sql.NullFloat64
|
||||||
var lastCompletedAt sql.NullString
|
var lastCompletedAt sql.NullString
|
||||||
|
var nextShowAt sql.NullString
|
||||||
var repetitionPeriod sql.NullString
|
var repetitionPeriod sql.NullString
|
||||||
|
|
||||||
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
|
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
|
||||||
var repetitionPeriodStr string
|
var repetitionPeriodStr string
|
||||||
err = a.DB.QueryRow(`
|
err = a.DB.QueryRow(`
|
||||||
SELECT id, name, completed, last_completed_at, reward_message, progression_base,
|
SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base,
|
||||||
CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period
|
CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period
|
||||||
FROM tasks
|
FROM tasks
|
||||||
WHERE id = $1 AND user_id = $2 AND deleted = FALSE
|
WHERE id = $1 AND user_id = $2 AND deleted = FALSE
|
||||||
`, taskID, userID).Scan(
|
`, taskID, userID).Scan(
|
||||||
&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &rewardMessage, &progressionBase, &repetitionPeriodStr,
|
&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr,
|
||||||
)
|
)
|
||||||
|
|
||||||
log.Printf("Scanned repetition_period for task %d: String='%s'", taskID, repetitionPeriodStr)
|
log.Printf("Scanned repetition_period for task %d: String='%s'", taskID, repetitionPeriodStr)
|
||||||
@@ -6428,6 +6446,9 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
if lastCompletedAt.Valid {
|
if lastCompletedAt.Valid {
|
||||||
task.LastCompletedAt = &lastCompletedAt.String
|
task.LastCompletedAt = &lastCompletedAt.String
|
||||||
}
|
}
|
||||||
|
if nextShowAt.Valid {
|
||||||
|
task.NextShowAt = &nextShowAt.String
|
||||||
|
}
|
||||||
if repetitionPeriod.Valid && repetitionPeriod.String != "" {
|
if repetitionPeriod.Valid && repetitionPeriod.String != "" {
|
||||||
task.RepetitionPeriod = &repetitionPeriod.String
|
task.RepetitionPeriod = &repetitionPeriod.String
|
||||||
log.Printf("Task %d has repetition_period: %s", task.ID, repetitionPeriod.String)
|
log.Printf("Task %d has repetition_period: %s", task.ID, repetitionPeriod.String)
|
||||||
@@ -7460,21 +7481,21 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Задача никогда не будет переноситься в выполненные
|
// Задача никогда не будет переноситься в выполненные
|
||||||
_, err = a.DB.Exec(`
|
_, err = a.DB.Exec(`
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET completed = completed + 1
|
SET completed = completed + 1, next_show_at = NULL
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, taskID)
|
`, taskID)
|
||||||
} else {
|
} else {
|
||||||
// Обычный период: обновляем счетчик и last_completed_at
|
// Обычный период: обновляем счетчик и last_completed_at, сбрасываем next_show_at
|
||||||
_, err = a.DB.Exec(`
|
_, err = a.DB.Exec(`
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET completed = completed + 1, last_completed_at = NOW()
|
SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, taskID)
|
`, taskID)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_, err = a.DB.Exec(`
|
_, err = a.DB.Exec(`
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET completed = completed + 1, last_completed_at = NOW(), deleted = TRUE
|
SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL, deleted = TRUE
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, taskID)
|
`, taskID)
|
||||||
}
|
}
|
||||||
@@ -7514,6 +7535,82 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postponeTaskHandler переносит задачу на указанную дату
|
||||||
|
func (a *App) postponeTaskHandler(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 req PostponeTaskRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
log.Printf("Error decoding postpone task request: %v", err)
|
||||||
|
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем владельца
|
||||||
|
var ownerID int
|
||||||
|
err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1 AND deleted = FALSE", taskID).Scan(&ownerID)
|
||||||
|
if err == sql.ErrNoRows || ownerID != userID {
|
||||||
|
sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error checking task ownership: %v", err)
|
||||||
|
sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если NextShowAt == nil, устанавливаем next_show_at в NULL
|
||||||
|
// Иначе парсим дату и устанавливаем значение
|
||||||
|
var nextShowAtValue interface{}
|
||||||
|
if req.NextShowAt == nil || *req.NextShowAt == "" {
|
||||||
|
nextShowAtValue = nil
|
||||||
|
} else {
|
||||||
|
nextShowAt, err := time.Parse(time.RFC3339, *req.NextShowAt)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error parsing next_show_at: %v", err)
|
||||||
|
sendErrorWithCORS(w, "Invalid date format. Use RFC3339 format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextShowAtValue = nextShowAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем next_show_at
|
||||||
|
_, err = a.DB.Exec(`
|
||||||
|
UPDATE tasks
|
||||||
|
SET next_show_at = $1
|
||||||
|
WHERE id = $2 AND user_id = $3
|
||||||
|
`, nextShowAtValue, taskID, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error updating next_show_at: %v", err)
|
||||||
|
sendErrorWithCORS(w, fmt.Sprintf("Error updating next_show_at: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "Task postponed successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// todoistDisconnectHandler отключает интеграцию Todoist
|
// todoistDisconnectHandler отключает интеграцию Todoist
|
||||||
func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) {
|
func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == "OPTIONS" {
|
if r.Method == "OPTIONS" {
|
||||||
|
|||||||
14
play-life-backend/migrations/017_add_next_show_at.sql
Normal file
14
play-life-backend/migrations/017_add_next_show_at.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- Migration: Add next_show_at field to tasks table
|
||||||
|
-- This script adds the next_show_at field for postponing tasks
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add next_show_at column
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS next_show_at TIMESTAMP WITH TIME ZONE;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON COLUMN tasks.next_show_at IS 'Date when task should be shown again (NULL means use last_completed_at + period)';
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "3.1.5",
|
"version": "3.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -96,6 +96,13 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-name-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
.task-name {
|
.task-name {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -105,6 +112,12 @@
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-next-show-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.task-subtasks-count {
|
.task-subtasks-count {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -128,6 +141,156 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-postpone-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-button:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-modal-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-close-button:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-modal-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-task-name {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-input {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-modal-actions {
|
||||||
|
padding: 1rem 1.5rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-cancel-button,
|
||||||
|
.task-postpone-submit-button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-cancel-button {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-cancel-button:hover:not(:disabled) {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-submit-button {
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-submit-button:hover:not(:disabled) {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-postpone-submit-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.task-menu-button {
|
.task-menu-button {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
|||||||
const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null)
|
const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null)
|
||||||
const [isCompleting, setIsCompleting] = useState(false)
|
const [isCompleting, setIsCompleting] = useState(false)
|
||||||
const [expandedCompleted, setExpandedCompleted] = useState({})
|
const [expandedCompleted, setExpandedCompleted] = useState({})
|
||||||
|
const [selectedTaskForPostpone, setSelectedTaskForPostpone] = useState(null)
|
||||||
|
const [postponeDate, setPostponeDate] = useState('')
|
||||||
|
const [isPostponing, setIsPostponing] = useState(false)
|
||||||
// Загружаем состояние раскрытия "Бесконечные" из localStorage (по умолчанию true)
|
// Загружаем состояние раскрытия "Бесконечные" из localStorage (по умолчанию true)
|
||||||
const [expandedInfinite, setExpandedInfinite] = useState(() => {
|
const [expandedInfinite, setExpandedInfinite] = useState(() => {
|
||||||
try {
|
try {
|
||||||
@@ -87,6 +90,94 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
|||||||
onNavigate?.('task-form', { taskId: undefined })
|
onNavigate?.('task-form', { taskId: undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePostponeClick = (task, e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedTaskForPostpone(task)
|
||||||
|
// Устанавливаем дату по умолчанию - завтра
|
||||||
|
const tomorrow = new Date()
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
|
tomorrow.setHours(0, 0, 0, 0)
|
||||||
|
setPostponeDate(tomorrow.toISOString().split('T')[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePostponeSubmit = async () => {
|
||||||
|
if (!selectedTaskForPostpone || !postponeDate) return
|
||||||
|
|
||||||
|
setIsPostponing(true)
|
||||||
|
try {
|
||||||
|
// Преобразуем дату в ISO формат с временем
|
||||||
|
const dateObj = new Date(postponeDate)
|
||||||
|
dateObj.setHours(0, 0, 0, 0)
|
||||||
|
const isoDate = dateObj.toISOString()
|
||||||
|
|
||||||
|
const response = await authFetch(`${API_URL}/${selectedTaskForPostpone.id}/postpone`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ next_show_at: isoDate }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.message || 'Ошибка при переносе задачи')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем список
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрываем модальное окно
|
||||||
|
setSelectedTaskForPostpone(null)
|
||||||
|
setPostponeDate('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error postponing task:', err)
|
||||||
|
alert(err.message || 'Ошибка при переносе задачи')
|
||||||
|
} finally {
|
||||||
|
setIsPostponing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePostponeReset = async () => {
|
||||||
|
if (!selectedTaskForPostpone) return
|
||||||
|
|
||||||
|
setIsPostponing(true)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/${selectedTaskForPostpone.id}/postpone`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ next_show_at: null }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.message || 'Ошибка при сбросе переноса задачи')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем список
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрываем модальное окно
|
||||||
|
setSelectedTaskForPostpone(null)
|
||||||
|
setPostponeDate('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error resetting postpone:', err)
|
||||||
|
alert(err.message || 'Ошибка при сбросе переноса задачи')
|
||||||
|
} finally {
|
||||||
|
setIsPostponing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePostponeClose = () => {
|
||||||
|
setSelectedTaskForPostpone(null)
|
||||||
|
setPostponeDate('')
|
||||||
|
}
|
||||||
|
|
||||||
const toggleCompletedExpanded = (projectName) => {
|
const toggleCompletedExpanded = (projectName) => {
|
||||||
setExpandedCompleted(prev => ({
|
setExpandedCompleted(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -195,31 +286,42 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
|||||||
let isCompleted = false
|
let isCompleted = false
|
||||||
let isInfinite = false
|
let isInfinite = false
|
||||||
|
|
||||||
// Если у задачи период повторения = 0, она в бесконечных
|
// Если next_show_at установлен, задача всегда в выполненных (если дата в будущем)
|
||||||
if (task.repetition_period && isZeroPeriod(task.repetition_period)) {
|
// даже если она бесконечная
|
||||||
|
if (task.next_show_at) {
|
||||||
|
const nextShowDate = new Date(task.next_show_at)
|
||||||
|
nextShowDate.setHours(0, 0, 0, 0)
|
||||||
|
isCompleted = nextShowDate.getTime() > today.getTime()
|
||||||
|
isInfinite = false
|
||||||
|
} else if (task.repetition_period && isZeroPeriod(task.repetition_period)) {
|
||||||
|
// Если у задачи период повторения = 0 и нет next_show_at, она в бесконечных
|
||||||
isInfinite = true
|
isInfinite = true
|
||||||
isCompleted = false
|
isCompleted = false
|
||||||
} else if (task.repetition_period) {
|
} else if (task.repetition_period) {
|
||||||
// Если есть repetition_period (и он не 0), проверяем логику повторения
|
// Если есть repetition_period (и он не 0), проверяем логику повторения
|
||||||
|
// Используем last_completed_at + period
|
||||||
|
let nextDueDate = null
|
||||||
|
|
||||||
if (task.last_completed_at) {
|
if (task.last_completed_at) {
|
||||||
const lastCompleted = new Date(task.last_completed_at)
|
const lastCompleted = new Date(task.last_completed_at)
|
||||||
const nextDueDate = addIntervalToDate(lastCompleted, task.repetition_period)
|
nextDueDate = addIntervalToDate(lastCompleted, task.repetition_period)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextDueDate) {
|
||||||
|
// Округляем до начала дня
|
||||||
|
nextDueDate.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
if (nextDueDate) {
|
// Если nextDueDate > today, то задача в выполненных
|
||||||
// Округляем до начала дня
|
isCompleted = nextDueDate.getTime() > today.getTime()
|
||||||
nextDueDate.setHours(0, 0, 0, 0)
|
} else {
|
||||||
|
// Если не удалось определить дату, используем старую логику
|
||||||
// Если nextDueDate > today, то задача в выполненных
|
if (task.last_completed_at) {
|
||||||
isCompleted = nextDueDate.getTime() > today.getTime()
|
|
||||||
} else {
|
|
||||||
// Если не удалось распарсить интервал, используем старую логику
|
|
||||||
const completedDate = new Date(task.last_completed_at)
|
const completedDate = new Date(task.last_completed_at)
|
||||||
completedDate.setHours(0, 0, 0, 0)
|
completedDate.setHours(0, 0, 0, 0)
|
||||||
isCompleted = completedDate.getTime() === today.getTime()
|
isCompleted = completedDate.getTime() === today.getTime()
|
||||||
|
} else {
|
||||||
|
isCompleted = false
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Если нет last_completed_at, то в обычной группе
|
|
||||||
isCompleted = false
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Если repetition_period == null, используем старую логику
|
// Если repetition_period == null, используем старую логику
|
||||||
@@ -277,32 +379,66 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="task-name-container">
|
<div className="task-name-container">
|
||||||
<div className="task-name">
|
<div className="task-name-wrapper">
|
||||||
{task.name}
|
<div className="task-name">
|
||||||
{hasSubtasks && (
|
{task.name}
|
||||||
<span className="task-subtasks-count">(+{task.subtasks_count})</span>
|
{hasSubtasks && (
|
||||||
)}
|
<span className="task-subtasks-count">(+{task.subtasks_count})</span>
|
||||||
|
)}
|
||||||
|
{hasProgression && (
|
||||||
|
<svg
|
||||||
|
className="task-progression-icon"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
title="Задача с прогрессией"
|
||||||
|
>
|
||||||
|
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline>
|
||||||
|
<polyline points="17 6 23 6 23 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{task.next_show_at && (() => {
|
||||||
|
const showDate = new Date(task.next_show_at)
|
||||||
|
showDate.setHours(0, 0, 0, 0)
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
const tomorrow = new Date(today)
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
|
|
||||||
|
let dateText
|
||||||
|
if (showDate.getTime() === today.getTime()) {
|
||||||
|
dateText = 'Сегодня'
|
||||||
|
} else if (showDate.getTime() === tomorrow.getTime()) {
|
||||||
|
dateText = 'Завтра'
|
||||||
|
} else {
|
||||||
|
dateText = showDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="task-next-show-date">
|
||||||
|
{dateText}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{hasProgression && (
|
|
||||||
<svg
|
|
||||||
className="task-progression-icon"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
title="Задача с прогрессией"
|
|
||||||
>
|
|
||||||
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline>
|
|
||||||
<polyline points="17 6 23 6 23 12"></polyline>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="task-actions">
|
<div className="task-actions">
|
||||||
<span className="task-completed-count">{task.completed}</span>
|
<button
|
||||||
|
className="task-postpone-button"
|
||||||
|
onClick={(e) => handlePostponeClick(task, e)}
|
||||||
|
title="Перенести задачу"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="10" cy="10" r="8" stroke="currentColor" strokeWidth="1.5" fill="none"/>
|
||||||
|
<path d="M10 5V10L13 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -365,12 +501,14 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
|||||||
<h3 className="project-group-title">{projectName}</h3>
|
<h3 className="project-group-title">{projectName}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Обычные задачи */}
|
||||||
{group.notCompleted.length > 0 && (
|
{group.notCompleted.length > 0 && (
|
||||||
<div className="task-group">
|
<div className="task-group">
|
||||||
{group.notCompleted.map(renderTaskItem)}
|
{group.notCompleted.map(renderTaskItem)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Бесконечные задачи */}
|
||||||
{hasInfinite && (
|
{hasInfinite && (
|
||||||
<div className="completed-section">
|
<div className="completed-section">
|
||||||
<button
|
<button
|
||||||
@@ -390,6 +528,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Выполненные задачи */}
|
||||||
{hasCompleted && (
|
{hasCompleted && (
|
||||||
<div className="completed-section">
|
<div className="completed-section">
|
||||||
<button
|
<button
|
||||||
@@ -425,6 +564,49 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
|||||||
onTaskCompleted={() => setToast({ message: 'Задача выполнена' })}
|
onTaskCompleted={() => setToast({ message: 'Задача выполнена' })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Модальное окно для переноса задачи */}
|
||||||
|
{selectedTaskForPostpone && (
|
||||||
|
<div className="task-postpone-modal-overlay" onClick={handlePostponeClose}>
|
||||||
|
<div className="task-postpone-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="task-postpone-modal-header">
|
||||||
|
<h3>Перенести задачу</h3>
|
||||||
|
<button onClick={handlePostponeClose} className="task-postpone-close-button">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="task-postpone-modal-content">
|
||||||
|
<p className="task-postpone-task-name">{selectedTaskForPostpone.name}</p>
|
||||||
|
<label className="task-postpone-label">
|
||||||
|
Дата показа:
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={postponeDate}
|
||||||
|
onChange={(e) => setPostponeDate(e.target.value)}
|
||||||
|
className="task-postpone-input"
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="task-postpone-modal-actions">
|
||||||
|
<button
|
||||||
|
onClick={handlePostponeReset}
|
||||||
|
className="task-postpone-cancel-button"
|
||||||
|
disabled={isPostponing}
|
||||||
|
>
|
||||||
|
{isPostponing ? 'Сброс...' : 'Сбросить'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePostponeSubmit}
|
||||||
|
className="task-postpone-submit-button"
|
||||||
|
disabled={isPostponing || !postponeDate}
|
||||||
|
>
|
||||||
|
{isPostponing ? 'Перенос...' : 'Перенести'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user