4.3.0: Автовыполнение задач в конце дня
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s

This commit is contained in:
poignatov
2026-01-29 17:47:47 +03:00
parent 5c5fc07481
commit f266508d04
7 changed files with 382 additions and 298 deletions

View File

@@ -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;
}

View File

@@ -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>
</>
)}

View File

@@ -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;

View File

@@ -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">