diff --git a/VERSION b/VERSION index af8c8ec..8089590 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.2.2 +4.3.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 73b0255..9915dad 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -232,6 +232,7 @@ type Task struct { ProjectNames []string `json:"project_names"` SubtasksCount int `json:"subtasks_count"` HasProgression bool `json:"has_progression"` + AutoComplete bool `json:"auto_complete"` } type Reward struct { @@ -6567,8 +6568,10 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { JOIN projects p ON rc.project_id = p.id WHERE st.parent_task_id = t.id AND st.deleted = FALSE), ARRAY[]::text[] - ) as subtask_project_names + ) as subtask_project_names, + COALESCE(td.auto_complete, FALSE) as auto_complete FROM tasks t + LEFT JOIN task_drafts td ON td.task_id = t.id AND td.user_id = $1 WHERE t.user_id = $1 AND t.parent_task_id IS NULL AND t.deleted = FALSE ORDER BY CASE WHEN t.last_completed_at IS NULL OR t.last_completed_at::date < CURRENT_DATE THEN 0 ELSE 1 END, @@ -6596,6 +6599,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { var rewardPolicy sql.NullString var projectNames pq.StringArray var subtaskProjectNames pq.StringArray + var autoComplete bool err := rows.Scan( &task.ID, @@ -6612,6 +6616,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { &task.SubtasksCount, &projectNames, &subtaskProjectNames, + &autoComplete, ) if err != nil { log.Printf("Error scanning task: %v", err) @@ -6647,6 +6652,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { if rewardPolicy.Valid { task.RewardPolicy = &rewardPolicy.String } + task.AutoComplete = autoComplete // Объединяем проекты из основной задачи и подзадач allProjects := make(map[string]bool) @@ -6894,12 +6900,86 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { } } + // Инициализируем auto_complete значением по умолчанию + task.AutoComplete = false + + // Загружаем данные из драфта, если он существует + var draftProgressionValue sql.NullFloat64 + var draftAutoComplete sql.NullBool + var draftProgressionValuePtr *float64 + var draftSubtasks []DraftSubtask + err = a.DB.QueryRow(` + SELECT progression_value, auto_complete + FROM task_drafts + WHERE task_id = $1 AND user_id = $2 + `, taskID, userID).Scan(&draftProgressionValue, &draftAutoComplete) + + if err == nil { + // Драфт существует, загружаем данные + if draftProgressionValue.Valid { + draftProgressionValuePtr = &draftProgressionValue.Float64 + } + // Устанавливаем auto_complete из драфта (если Valid, иначе остается false) + if draftAutoComplete.Valid { + task.AutoComplete = draftAutoComplete.Bool + log.Printf("Task %d: auto_complete set to %v from draft", taskID, task.AutoComplete) + } else { + log.Printf("Task %d: draft exists but auto_complete is NULL, keeping default false", taskID) + } + + // Загружаем подзадачи из драфта + draftSubtaskRows, err := a.DB.Query(` + SELECT subtask_id + FROM task_draft_subtasks + WHERE task_draft_id = (SELECT id FROM task_drafts WHERE task_id = $1 AND user_id = $2) + `, taskID, userID) + if err == nil { + defer draftSubtaskRows.Close() + draftSubtasks = make([]DraftSubtask, 0) + validSubtaskIDs := make(map[int]bool) + // Создаем map валидных подзадач для фильтрации + for _, subtask := range subtasks { + validSubtaskIDs[subtask.Task.ID] = true + } + + for draftSubtaskRows.Next() { + var subtaskID int + if err := draftSubtaskRows.Scan(&subtaskID); err == nil { + // Игнорируем подзадачи, которых больше нет в основной задаче + if validSubtaskIDs[subtaskID] { + draftSubtasks = append(draftSubtasks, DraftSubtask{ + SubtaskID: subtaskID, + }) + } + } + } + } else if err != sql.ErrNoRows { + log.Printf("Error loading draft subtasks for task %d: %v", taskID, err) + } + } else if err != sql.ErrNoRows { + log.Printf("Error loading draft for task %d: %v", taskID, err) + } else { + log.Printf("Task %d: no draft found, auto_complete remains false", taskID) + } + // Если драфта нет (err == sql.ErrNoRows), auto_complete остается false + log.Printf("Task %d: final auto_complete value = %v", taskID, task.AutoComplete) + response := TaskDetail{ Task: task, Rewards: rewards, Subtasks: subtasks, } + // Устанавливаем DraftProgressionValue если он был загружен + if draftProgressionValuePtr != nil { + response.DraftProgressionValue = draftProgressionValuePtr + } + + // Устанавливаем DraftSubtasks если они были загружены + if len(draftSubtasks) > 0 { + response.DraftSubtasks = draftSubtasks + } + // Если задача связана с wishlist, загружаем базовую информацию о wishlist if wishlistID.Valid { var wishlistName string @@ -6926,56 +7006,6 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { } } - // Загружаем данные из драфта, если он существует - var draftProgressionValue sql.NullFloat64 - err = a.DB.QueryRow(` - SELECT progression_value - FROM task_drafts - WHERE task_id = $1 AND user_id = $2 - `, taskID, userID).Scan(&draftProgressionValue) - - if err == nil { - // Драфт существует, загружаем данные - if draftProgressionValue.Valid { - response.DraftProgressionValue = &draftProgressionValue.Float64 - } - - // Загружаем подзадачи из драфта - draftSubtaskRows, err := a.DB.Query(` - SELECT subtask_id - FROM task_draft_subtasks - WHERE task_draft_id = (SELECT id FROM task_drafts WHERE task_id = $1 AND user_id = $2) - `, taskID, userID) - if err == nil { - defer draftSubtaskRows.Close() - draftSubtasks := make([]DraftSubtask, 0) - validSubtaskIDs := make(map[int]bool) - // Создаем map валидных подзадач для фильтрации - for _, subtask := range subtasks { - validSubtaskIDs[subtask.Task.ID] = true - } - - for draftSubtaskRows.Next() { - var subtaskID int - if err := draftSubtaskRows.Scan(&subtaskID); err == nil { - // Игнорируем подзадачи, которых больше нет в основной задаче - if validSubtaskIDs[subtaskID] { - draftSubtasks = append(draftSubtasks, DraftSubtask{ - SubtaskID: subtaskID, - }) - } - } - } - if len(draftSubtasks) > 0 { - response.DraftSubtasks = draftSubtasks - } - } else if err != sql.ErrNoRows { - log.Printf("Error loading draft subtasks for task %d: %v", taskID, err) - } - } else if err != sql.ErrNoRows { - log.Printf("Error loading draft for task %d: %v", taskID, err) - } - // Если задача - тест (есть config_id), загружаем данные конфигурации if configID.Valid { var wordsCount int @@ -7017,6 +7047,7 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { } } + log.Printf("Task %d: Sending response with auto_complete = %v (task.AutoComplete = %v)", taskID, response.Task.AutoComplete, task.AutoComplete) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } diff --git a/play-life-web/package.json b/play-life-web/package.json index b13250a..7a6ec80 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "4.2.2", + "version": "4.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/TaskDetail.css b/play-life-web/src/components/TaskDetail.css index e0ef83d..c1759c2 100644 --- a/play-life-web/src/components/TaskDetail.css +++ b/play-life-web/src/components/TaskDetail.css @@ -64,6 +64,14 @@ font-weight: 600; color: #1f2937; margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.task-detail-auto-complete-icon { + color: #6366f1; + flex-shrink: 0; } .task-reward-message { @@ -191,17 +199,70 @@ .task-actions-section { display: flex; flex-direction: column; + gap: 0.375rem; +} + +.task-actions-bottom { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0; +} + +.task-action-left { + flex: 1; + display: flex; +} + +.complete-at-end-of-day-checkbox { + margin-top: 0; +} + +.complete-at-end-of-day-checkbox .checkbox-label { + font-size: 0.85rem; gap: 0.25rem; } +.complete-at-end-of-day-checkbox .checkbox-input { + width: 1rem; + height: 1rem; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.95rem; + color: #374151; + user-select: none; +} + +.checkbox-input { + width: 1.125rem; + height: 1.125rem; + cursor: pointer; + accent-color: #6366f1; +} + +.checkbox-label:has(.checkbox-input:disabled) { + opacity: 0.5; + cursor: not-allowed; +} + .task-actions-buttons { display: flex; gap: 0.75rem; - align-items: center; + align-items: stretch; } -.complete-button { - flex: 1; +.task-action-complete-buttons { + display: flex; + gap: 0.25rem; + align-items: stretch; +} + +.action-button { padding: 0.75rem 1.5rem; background: linear-gradient(to right, #6366f1, #8b5cf6); color: white; @@ -214,52 +275,69 @@ display: flex; align-items: center; justify-content: center; + line-height: 1.5; + height: calc(0.75rem * 2 + 1rem + 0.125rem * 2); } -.complete-button:hover:not(:disabled) { +.action-button:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); } -.complete-button:disabled { +.action-button:disabled { opacity: 0.5; cursor: not-allowed; } -.close-button-outline { +.action-button-check { + width: calc(0.75rem * 2 + 1rem + 0.125rem * 2); + min-width: calc(0.75rem * 2 + 1rem); + height: calc(0.75rem * 2 + 1rem + 0.125rem * 2); 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; + flex-shrink: 0; + box-sizing: border-box; + background: linear-gradient(to right, #10b981, #059669); + margin: 0; } -.close-button-outline:hover:not(:disabled) { +.action-button-check:hover:not(:disabled) { transform: translateY(-1px); - background: rgba(99, 102, 241, 0.1); - box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); } -.close-button-outline:disabled { - opacity: 0.5; - cursor: not-allowed; +.action-button-double-check { + width: calc(0.75rem * 2 + 1rem + 0.125rem * 2); + min-width: calc(0.75rem * 2 + 1rem); + height: calc(0.75rem * 2 + 1rem + 0.125rem * 2); + padding: 0.75rem; + flex-shrink: 0; + box-sizing: border-box; + background: transparent; + border: 2px solid #10b981; + color: #10b981; + margin: 0; } +.action-button-double-check:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); + background: rgba(16, 185, 129, 0.1); +} + +.action-button-save { + flex: 1; + width: 100%; +} + + .next-task-date-info { font-size: 0.875rem; color: #6b7280; - text-align: left; - margin-top: -0.125rem; - margin-bottom: -0.5rem; + text-align: right; +} + +.next-task-date-bold { + font-weight: 600; } .loading, @@ -317,77 +395,3 @@ text-decoration: none; } -/* Dropdown styles */ -.dropdown-container { - position: relative; - display: inline-block; -} - -.dropdown-button { - padding: 0; - 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; - width: calc(0.75rem * 2 + 1.2rem + 4px); - height: calc(0.75rem * 2 + 1.2rem + 4px); - box-sizing: border-box; -} - -.dropdown-button:hover:not(:disabled) { - transform: translateY(-1px); - background: rgba(99, 102, 241, 0.1); - box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); -} - -.dropdown-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.dropdown-menu { - position: fixed; - background: white; - border: 1px solid #e5e7eb; - border-radius: 0.5rem; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); - min-width: 240px; - z-index: 1800; - overflow: hidden; -} - -.dropdown-item { - width: 100%; - padding: 0.5rem 1rem; - background: none; - border: none; - text-align: left; - font-size: 0.95rem; - color: #374151; - cursor: pointer; - transition: background-color 0.2s; - display: flex; - align-items: center; - border-bottom: 1px solid #f3f4f6; -} - -.dropdown-item:last-child { - border-bottom: none; -} - -.dropdown-item:hover:not(:disabled) { - background-color: #f9fafb; - color: #1f2937; -} - -.dropdown-item:disabled { - opacity: 0.5; - cursor: not-allowed; -} diff --git a/play-life-web/src/components/TaskDetail.jsx b/play-life-web/src/components/TaskDetail.jsx index e9d5ee1..5c9b9ff 100644 --- a/play-life-web/src/components/TaskDetail.jsx +++ b/play-life-web/src/components/TaskDetail.jsx @@ -383,10 +383,8 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) const [isCompleting, setIsCompleting] = useState(false) const [toastMessage, setToastMessage] = useState(null) const [wishlistInfo, setWishlistInfo] = useState(null) - const [showDropdown, setShowDropdown] = useState(false) const [isSaving, setIsSaving] = useState(false) - const [dropdownPosition, setDropdownPosition] = useState({ top: 0, right: 0 }) - const dropdownButtonRef = useRef(null) + const [completeAtEndOfDay, setCompleteAtEndOfDay] = useState(false) const fetchTaskDetail = useCallback(async () => { try { @@ -429,6 +427,8 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) } setSelectedSubtasks(validSubtaskIDs) } + + // Значение чекбокса будет установлено в useEffect при изменении taskDetail } catch (err) { setError(err.message) console.error('Error fetching task detail:', err) @@ -447,6 +447,7 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) setError(null) setSelectedSubtasks(new Set()) setProgressionValue('') + setCompleteAtEndOfDay(false) } }, [taskId, fetchTaskDetail]) @@ -462,9 +463,12 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) }) } - const handleSaveDraft = async (autoComplete = false) => { + const handleSave = async () => { if (!taskDetail) return + // Если чекбокс включен - выполняем в конце дня, иначе сохраняем без автовыполнения + const autoComplete = completeAtEndOfDay + setIsSaving(true) try { const payload = { @@ -518,7 +522,10 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) type: 'success' }) - // Обновляем данные задачи + // Обновляем данные задачи, чтобы получить актуальное значение auto_complete + await fetchTaskDetail() + + // Обновляем данные задачи в списке if (onRefresh) { onRefresh() } @@ -535,11 +542,7 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) } } - const handleCompleteAtEndOfDay = async () => { - await handleSaveDraft(true) - } - - const handleComplete = async (shouldDelete = false) => { + const handleComplete = async () => { if (!taskDetail) return // Проверяем, что желание разблокировано (если есть связанное желание) @@ -548,8 +551,6 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) return } - // Если прогрессия не введена, используем 0 (валидация не требуется) - setIsCompleting(true) try { const payload = { @@ -569,10 +570,70 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) } } - // Используем единую ручку для выполнения и удаления - const endpoint = shouldDelete - ? `${API_URL}/${taskId}/complete-and-delete` - : `${API_URL}/${taskId}/complete` + const endpoint = `${API_URL}/${taskId}/complete` + + const response = await authFetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.message || 'Ошибка при выполнении задачи') + } + + // Показываем уведомление о выполнении + if (onTaskCompleted) { + onTaskCompleted() + } + + // Обновляем список и закрываем модальное окно + if (onRefresh) { + onRefresh() + } + if (onClose) { + onClose() + } + } catch (err) { + console.error('Error completing task:', err) + setToastMessage({ text: err.message || 'Ошибка при выполнении задачи', type: 'error' }) + } finally { + setIsCompleting(false) + } + } + + const handleCompleteFinally = async () => { + if (!taskDetail) return + + // Проверяем, что желание разблокировано (если есть связанное желание) + if (wishlistInfo && !wishlistInfo.unlocked) { + setToastMessage({ text: 'Невозможно выполнить задачу: желание не разблокировано', type: 'error' }) + return + } + + setIsCompleting(true) + try { + const payload = { + children_task_ids: Array.from(selectedSubtasks) + } + + // Если есть прогрессия, отправляем значение (или progression_base, если не введено) + if (taskDetail.task.progression_base != null) { + if (progressionValue.trim()) { + payload.value = parseFloat(progressionValue) + if (isNaN(payload.value)) { + throw new Error('Неверное значение') + } + } else { + // Если прогрессия не введена - используем progression_base + payload.value = taskDetail.task.progression_base + } + } + + const endpoint = `${API_URL}/${taskId}/complete-and-delete` const response = await authFetch(endpoint, { method: 'POST', @@ -613,6 +674,7 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) const hasProgression = task?.progression_base != null // Кнопка активна только если желание разблокировано (или задачи нет связанного желания) const canComplete = !wishlistInfo || wishlistInfo.unlocked + const hasProgressionOrSubtasks = hasProgression || (subtasks && subtasks.length > 0) // Определяем, является ли задача одноразовой // Одноразовая задача: когда оба поля null/undefined (из бэкенда видно, что в этом случае задача помечается как deleted) @@ -652,31 +714,29 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) return formatTelegramMessage(task, rewards || [], subtasks || [], selectedSubtasks, progressionValue) }, [taskDetail, task, rewards, subtasks, selectedSubtasks, progressionValue]) - - // Закрываем dropdown при клике вне его + // Обновляем значение чекбокса при изменении taskDetail useEffect(() => { - const handleClickOutside = (event) => { - if (showDropdown && !event.target.closest('.dropdown-container') && !event.target.closest('.dropdown-menu')) { - setShowDropdown(false) - } + if (taskDetail && taskDetail.task) { + const autoCompleteValue = Boolean(taskDetail.task.auto_complete) + console.log('useEffect: Updating completeAtEndOfDay from taskDetail:', autoCompleteValue, 'task.auto_complete:', taskDetail.task.auto_complete) + setCompleteAtEndOfDay(autoCompleteValue) + } else { + setCompleteAtEndOfDay(false) } - document.addEventListener('mousedown', handleClickOutside) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, [showDropdown]) + }, [taskDetail]) + + return ( -
{ - // Закрываем модальное окно только если клик был не на dropdown - if (!e.target.closest('.dropdown-container') && !e.target.closest('.dropdown-menu')) { - onClose() - } - }}> +
e.stopPropagation()}>

- {loading ? 'Загрузка...' : error ? 'Ошибка' : taskDetail ? task.name : 'Задача'} + {loading ? 'Загрузка...' : error ? 'Ошибка' : taskDetail ? ( + <> + {task.name} + + ) : 'Задача'}

+
+ + {/* Правая часть: кнопки выполнения */} +
+ {/* Кнопка с одинарной галочкой */} + + + {/* Кнопка с двойной галочкой (только для повторяющихся задач) */} + {!isOneTime && ( + )} - {isCompleting ? 'Выполнение...' : 'Выполнить'} - - {/* Кнопка многоточие с dropdown */} - {(() => { - // Определяем доступные опции - const hasProgressionOrSubtasks = hasProgression || (subtasks && subtasks.length > 0) - const showCompleteFinally = !isOneTime && canComplete - const showCompleteAtEndOfDay = hasProgressionOrSubtasks && canComplete - const showSaveDraft = hasProgressionOrSubtasks && canComplete - - // Если нет доступных опций - не показываем кнопку - if (!showCompleteFinally && !showCompleteAtEndOfDay && !showSaveDraft) { - return null - } - - return ( -
- - {showDropdown && ( -
- {showCompleteFinally && ( - - )} - {showCompleteAtEndOfDay && ( - - )} - {showSaveDraft && ( - - )} -
- )} -
- ) - })()} -
- {!isOneTime && nextTaskDate && ( -
- Следующая: {nextTaskDate}
- )} +
+ + {/* Чекбокс и дата на одной линии */} +
+
+ +
+ {!isOneTime && nextTaskDate && ( +
+ {nextTaskDate} +
+ )} +
)} diff --git a/play-life-web/src/components/TaskList.css b/play-life-web/src/components/TaskList.css index b082823..cb05b58 100644 --- a/play-life-web/src/components/TaskList.css +++ b/play-life-web/src/components/TaskList.css @@ -88,6 +88,24 @@ color: #8b5cf6; } +.task-checkmark { + position: relative; +} + +.task-checkmark-auto-complete .checkmark-check { + opacity: 0 !important; +} + +.task-checkmark-auto-complete-icon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #9ca3af; + pointer-events: none; + z-index: 1; +} + .task-name-container { flex: 1; display: flex; @@ -154,6 +172,7 @@ flex-shrink: 0; } + .task-actions { display: flex; align-items: center; diff --git a/play-life-web/src/components/TaskList.jsx b/play-life-web/src/components/TaskList.jsx index 6bc3159..16ee4b4 100644 --- a/play-life-web/src/components/TaskList.jsx +++ b/play-life-web/src/components/TaskList.jsx @@ -538,7 +538,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry >
handleCheckmarkClick(task, e)} title={isTest ? 'Запустить тест' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу')} > @@ -579,6 +579,22 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry )} + {task.auto_complete && !isTest && !isWishlist && ( + + + + )}