feat: добавлена функциональность откладывания задач (next_show_at)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 41s

This commit is contained in:
poignatov
2026-01-06 15:56:52 +03:00
parent 1da35aaea4
commit 508355dcb3
6 changed files with 502 additions and 46 deletions

View File

@@ -13,6 +13,9 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null)
const [isCompleting, setIsCompleting] = useState(false)
const [expandedCompleted, setExpandedCompleted] = useState({})
const [selectedTaskForPostpone, setSelectedTaskForPostpone] = useState(null)
const [postponeDate, setPostponeDate] = useState('')
const [isPostponing, setIsPostponing] = useState(false)
// Загружаем состояние раскрытия "Бесконечные" из localStorage (по умолчанию true)
const [expandedInfinite, setExpandedInfinite] = useState(() => {
try {
@@ -87,6 +90,94 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
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) => {
setExpandedCompleted(prev => ({
...prev,
@@ -195,31 +286,42 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
let isCompleted = false
let isInfinite = false
// Если у задачи период повторения = 0, она в бесконечных
if (task.repetition_period && isZeroPeriod(task.repetition_period)) {
// Если next_show_at установлен, задача всегда в выполненных (если дата в будущем)
// даже если она бесконечная
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
isCompleted = false
} else if (task.repetition_period) {
// Если есть repetition_period (и он не 0), проверяем логику повторения
// Используем last_completed_at + period
let nextDueDate = null
if (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.setHours(0, 0, 0, 0)
// Если nextDueDate > today, то задача в выполненных
isCompleted = nextDueDate.getTime() > today.getTime()
} else {
// Если не удалось распарсить интервал, используем старую логику
// Если nextDueDate > today, то задача в выполненных
isCompleted = nextDueDate.getTime() > today.getTime()
} else {
// Если не удалось определить дату, используем старую логику
if (task.last_completed_at) {
const completedDate = new Date(task.last_completed_at)
completedDate.setHours(0, 0, 0, 0)
isCompleted = completedDate.getTime() === today.getTime()
} else {
isCompleted = false
}
} else {
// Если нет last_completed_at, то в обычной группе
isCompleted = false
}
} else {
// Если repetition_period == null, используем старую логику
@@ -277,32 +379,66 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
</svg>
</div>
<div className="task-name-container">
<div className="task-name">
{task.name}
{hasSubtasks && (
<span className="task-subtasks-count">(+{task.subtasks_count})</span>
)}
<div className="task-name-wrapper">
<div className="task-name">
{task.name}
{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>
{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 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>
@@ -365,12 +501,14 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
<h3 className="project-group-title">{projectName}</h3>
</div>
{/* Обычные задачи */}
{group.notCompleted.length > 0 && (
<div className="task-group">
{group.notCompleted.map(renderTaskItem)}
</div>
)}
{/* Бесконечные задачи */}
{hasInfinite && (
<div className="completed-section">
<button
@@ -390,6 +528,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
</div>
)}
{/* Выполненные задачи */}
{hasCompleted && (
<div className="completed-section">
<button
@@ -425,6 +564,49 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
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>
)
}