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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "3.1.5",
|
||||
"version": "3.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -96,6 +96,13 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.task-name-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
@@ -105,6 +112,12 @@
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.task-next-show-date {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.task-subtasks-count {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
@@ -128,6 +141,156 @@
|
||||
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 {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user