4.3.0: Автовыполнение задач в конце дня
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "4.2.2",
|
||||
"version": "4.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="task-detail-modal-overlay" onClick={(e) => {
|
||||
// Закрываем модальное окно только если клик был не на dropdown
|
||||
if (!e.target.closest('.dropdown-container') && !e.target.closest('.dropdown-menu')) {
|
||||
onClose()
|
||||
}
|
||||
}}>
|
||||
<div className="task-detail-modal-overlay" onClick={onClose}>
|
||||
<div className="task-detail-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="task-detail-modal-header">
|
||||
<h2 className="task-detail-title">
|
||||
{loading ? 'Загрузка...' : error ? 'Ошибка' : taskDetail ? task.name : 'Задача'}
|
||||
{loading ? 'Загрузка...' : error ? 'Ошибка' : taskDetail ? (
|
||||
<>
|
||||
{task.name}
|
||||
</>
|
||||
) : 'Задача'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="task-detail-close-button">
|
||||
✕
|
||||
@@ -778,118 +838,72 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate })
|
||||
{/* Кнопки действий */}
|
||||
<div className="task-actions-section">
|
||||
<div className="task-actions-buttons">
|
||||
<button
|
||||
onClick={() => handleComplete(false)}
|
||||
disabled={isCompleting || !canComplete}
|
||||
className="complete-button"
|
||||
title={!canComplete && wishlistInfo ? 'Желание не разблокировано' : ''}
|
||||
>
|
||||
{!canComplete && wishlistInfo ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '0.5rem' }}>
|
||||
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginRight: '0.5rem' }}>
|
||||
{/* Левая часть: кнопка "Сохранить" */}
|
||||
<div className="task-action-left">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !canComplete}
|
||||
className="action-button action-button-save"
|
||||
title={!canComplete && wishlistInfo ? 'Желание не разблокировано' : ''}
|
||||
>
|
||||
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Правая часть: кнопки выполнения */}
|
||||
<div className="task-action-complete-buttons">
|
||||
{/* Кнопка с одинарной галочкой */}
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
disabled={isCompleting || !canComplete}
|
||||
className="action-button action-button-check"
|
||||
title={!canComplete && wishlistInfo ? 'Желание не разблокировано' : 'Выполнить'}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5 4L6 11.5L2.5 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Кнопка с двойной галочкой (только для повторяющихся задач) */}
|
||||
{!isOneTime && (
|
||||
<button
|
||||
onClick={handleCompleteFinally}
|
||||
disabled={isCompleting || !canComplete}
|
||||
className="action-button action-button-double-check"
|
||||
title="Выполнить окончательно"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
<path d="M20 12L9 23l-5-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{isCompleting ? 'Выполнение...' : 'Выполнить'}
|
||||
</button>
|
||||
{/* Кнопка многоточие с 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 (
|
||||
<div className="dropdown-container" style={{ position: 'relative', zIndex: 1800 }}>
|
||||
<button
|
||||
ref={dropdownButtonRef}
|
||||
onClick={() => {
|
||||
const newShowState = !showDropdown
|
||||
if (newShowState && dropdownButtonRef.current) {
|
||||
// Вычисляем позицию синхронно перед открытием
|
||||
const buttonRect = dropdownButtonRef.current.getBoundingClientRect()
|
||||
setDropdownPosition({
|
||||
top: buttonRect.bottom + 8, // 8px = margin-top: 0.5rem
|
||||
right: window.innerWidth - buttonRect.right
|
||||
})
|
||||
}
|
||||
setShowDropdown(newShowState)
|
||||
}}
|
||||
disabled={isCompleting || isSaving || !canComplete}
|
||||
className="dropdown-button"
|
||||
title="Дополнительные действия"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="9" cy="4.5" r="1.5" fill="currentColor"/>
|
||||
<circle cx="9" cy="9" r="1.5" fill="currentColor"/>
|
||||
<circle cx="9" cy="13.5" r="1.5" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
{showDropdown && (
|
||||
<div
|
||||
className="dropdown-menu"
|
||||
style={{
|
||||
zIndex: 1800,
|
||||
position: 'fixed',
|
||||
top: `${dropdownPosition.top}px`,
|
||||
right: `${dropdownPosition.right}px`
|
||||
}}
|
||||
>
|
||||
{showCompleteFinally && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDropdown(false)
|
||||
handleComplete(true)
|
||||
}}
|
||||
className="dropdown-item"
|
||||
>
|
||||
Выполнить окончательно
|
||||
</button>
|
||||
)}
|
||||
{showCompleteAtEndOfDay && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDropdown(false)
|
||||
handleCompleteAtEndOfDay()
|
||||
}}
|
||||
className="dropdown-item"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Сохранение...' : 'Выполнить в конце дня'}
|
||||
</button>
|
||||
)}
|
||||
{showSaveDraft && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDropdown(false)
|
||||
handleSaveDraft(false)
|
||||
}}
|
||||
className="dropdown-item"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Сохранение...' : 'Сохранить без автовыполнения'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{!isOneTime && nextTaskDate && (
|
||||
<div className="next-task-date-info">
|
||||
Следующая: {nextTaskDate}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Чекбокс и дата на одной линии */}
|
||||
<div className="task-actions-bottom">
|
||||
<div className="complete-at-end-of-day-checkbox">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={completeAtEndOfDay}
|
||||
onChange={(e) => {
|
||||
console.log('Checkbox changed to:', e.target.checked)
|
||||
setCompleteAtEndOfDay(e.target.checked)
|
||||
}}
|
||||
disabled={isSaving || !canComplete}
|
||||
className="checkbox-input"
|
||||
/>
|
||||
<span>Выполнить в конце дня</span>
|
||||
</label>
|
||||
</div>
|
||||
{!isOneTime && nextTaskDate && (
|
||||
<div className="next-task-date-info">
|
||||
<span className="next-task-date-bold">{nextTaskDate}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -538,7 +538,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
||||
>
|
||||
<div className="task-item-content">
|
||||
<div
|
||||
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''}`}
|
||||
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''} ${task.auto_complete ? 'task-checkmark-auto-complete' : ''}`}
|
||||
onClick={(e) => handleCheckmarkClick(task, e)}
|
||||
title={isTest ? 'Запустить тест' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу')}
|
||||
>
|
||||
@@ -579,6 +579,22 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
||||
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
|
||||
</svg>
|
||||
)}
|
||||
{task.auto_complete && !isTest && !isWishlist && (
|
||||
<svg
|
||||
className="task-checkmark-auto-complete-icon"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
title="Автовыполнение в конце дня"
|
||||
>
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="task-name-container">
|
||||
<div className="task-name-wrapper">
|
||||
|
||||
Reference in New Issue
Block a user