v2.9.0: Улучшения экрана списка задач - оптимизация загрузки, toast уведомления, исправление центрирования
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 44s
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 44s
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "2.6.1",
|
||||
"version": "2.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -8,6 +8,8 @@ import TestConfigSelection from './components/TestConfigSelection'
|
||||
import AddConfig from './components/AddConfig'
|
||||
import TestWords from './components/TestWords'
|
||||
import Profile from './components/Profile'
|
||||
import TaskList from './components/TaskList'
|
||||
import TaskForm from './components/TaskForm'
|
||||
import { AuthProvider, useAuth } from './components/auth/AuthContext'
|
||||
import AuthScreen from './components/auth/AuthScreen'
|
||||
|
||||
@@ -42,6 +44,8 @@ function AppContent() {
|
||||
'test-config': false,
|
||||
'add-config': false,
|
||||
test: false,
|
||||
tasks: false,
|
||||
'task-form': false,
|
||||
profile: false,
|
||||
})
|
||||
|
||||
@@ -55,6 +59,8 @@ function AppContent() {
|
||||
'test-config': false,
|
||||
'add-config': false,
|
||||
test: false,
|
||||
tasks: false,
|
||||
'task-form': false,
|
||||
profile: false,
|
||||
})
|
||||
|
||||
@@ -64,16 +70,19 @@ function AppContent() {
|
||||
// Кеширование данных
|
||||
const [currentWeekData, setCurrentWeekData] = useState(null)
|
||||
const [fullStatisticsData, setFullStatisticsData] = useState(null)
|
||||
const [tasksData, setTasksData] = useState(null)
|
||||
|
||||
// Состояния загрузки для каждого таба (показываются только при первой загрузке)
|
||||
const [currentWeekLoading, setCurrentWeekLoading] = useState(false)
|
||||
const [fullStatisticsLoading, setFullStatisticsLoading] = useState(false)
|
||||
const [prioritiesLoading, setPrioritiesLoading] = useState(false)
|
||||
const [tasksLoading, setTasksLoading] = useState(false)
|
||||
|
||||
// Состояния фоновой загрузки (не показываются визуально)
|
||||
const [currentWeekBackgroundLoading, setCurrentWeekBackgroundLoading] = useState(false)
|
||||
const [fullStatisticsBackgroundLoading, setFullStatisticsBackgroundLoading] = useState(false)
|
||||
const [prioritiesBackgroundLoading, setPrioritiesBackgroundLoading] = useState(false)
|
||||
const [tasksBackgroundLoading, setTasksBackgroundLoading] = useState(false)
|
||||
|
||||
// Ошибки
|
||||
const [currentWeekError, setCurrentWeekError] = useState(null)
|
||||
@@ -94,7 +103,7 @@ function AppContent() {
|
||||
|
||||
try {
|
||||
const savedTab = window.localStorage?.getItem('activeTab')
|
||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'profile']
|
||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'profile']
|
||||
if (savedTab && validTabs.includes(savedTab)) {
|
||||
setActiveTab(savedTab)
|
||||
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
|
||||
@@ -194,6 +203,30 @@ function AppContent() {
|
||||
}
|
||||
}, [authFetch])
|
||||
|
||||
const fetchTasksData = useCallback(async (isBackground = false) => {
|
||||
try {
|
||||
if (isBackground) {
|
||||
setTasksBackgroundLoading(true)
|
||||
} else {
|
||||
setTasksLoading(true)
|
||||
}
|
||||
const response = await authFetch('/api/tasks')
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка загрузки данных')
|
||||
}
|
||||
const jsonData = await response.json()
|
||||
setTasksData(jsonData)
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки списка задач:', err)
|
||||
} finally {
|
||||
if (isBackground) {
|
||||
setTasksBackgroundLoading(false)
|
||||
} else {
|
||||
setTasksLoading(false)
|
||||
}
|
||||
}
|
||||
}, [authFetch])
|
||||
|
||||
// Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
|
||||
const tabsInitializedRef = useRef({
|
||||
current: false,
|
||||
@@ -204,6 +237,8 @@ function AppContent() {
|
||||
'test-config': false,
|
||||
'add-config': false,
|
||||
test: false,
|
||||
tasks: false,
|
||||
'task-form': false,
|
||||
profile: false,
|
||||
})
|
||||
|
||||
@@ -211,6 +246,7 @@ function AppContent() {
|
||||
const cacheRef = useRef({
|
||||
current: null,
|
||||
full: null,
|
||||
tasks: null,
|
||||
})
|
||||
|
||||
// Обновляем ref при изменении данных
|
||||
@@ -222,6 +258,10 @@ function AppContent() {
|
||||
cacheRef.current.full = fullStatisticsData
|
||||
}, [fullStatisticsData])
|
||||
|
||||
useEffect(() => {
|
||||
cacheRef.current.tasks = tasksData
|
||||
}, [tasksData])
|
||||
|
||||
// Функция для загрузки данных таба
|
||||
const loadTabData = useCallback((tab, isBackground = false) => {
|
||||
if (tab === 'current') {
|
||||
@@ -275,8 +315,21 @@ function AppContent() {
|
||||
// Возврат на таб - фоновая загрузка
|
||||
setTestConfigRefreshTrigger(prev => prev + 1)
|
||||
}
|
||||
} else if (tab === 'tasks') {
|
||||
const hasCache = cacheRef.current.tasks !== null
|
||||
const isInitialized = tabsInitializedRef.current.tasks
|
||||
|
||||
if (!isInitialized) {
|
||||
// Первая загрузка таба - загружаем с индикатором
|
||||
fetchTasksData(false)
|
||||
tabsInitializedRef.current.tasks = true
|
||||
setTabsInitialized(prev => ({ ...prev, tasks: true }))
|
||||
} else if (hasCache && isBackground) {
|
||||
// Возврат на таб с кешем - фоновая загрузка
|
||||
fetchTasksData(true)
|
||||
}
|
||||
}, [fetchCurrentWeekData, fetchFullStatisticsData])
|
||||
}
|
||||
}, [fetchCurrentWeekData, fetchFullStatisticsData, fetchTasksData])
|
||||
|
||||
// Функция для обновления всех данных (для кнопки Refresh, если она есть)
|
||||
const refreshAllData = useCallback(async () => {
|
||||
@@ -325,14 +378,20 @@ function AppContent() {
|
||||
if (tab === 'full' && activeTab === 'full') {
|
||||
// При повторном клике на "Полная статистика" сбрасываем выбранный проект
|
||||
setSelectedProject(null)
|
||||
} else if (tab !== activeTab) {
|
||||
} else if (tab !== activeTab || tab === 'task-form') {
|
||||
// Для task-form всегда обновляем параметры, даже если это тот же таб
|
||||
markTabAsLoaded(tab)
|
||||
// Сбрасываем tabParams при переходе с add-config на другой таб
|
||||
if (activeTab === 'add-config' && tab !== 'add-config') {
|
||||
setTabParams({})
|
||||
} else {
|
||||
// Для task-form явно удаляем taskId, если он undefined
|
||||
if (tab === 'task-form' && params.taskId === undefined) {
|
||||
setTabParams({})
|
||||
} else {
|
||||
setTabParams(params)
|
||||
}
|
||||
}
|
||||
setActiveTab(tab)
|
||||
if (tab === 'current') {
|
||||
setSelectedProject(null)
|
||||
@@ -341,6 +400,11 @@ function AppContent() {
|
||||
if (activeTab === 'add-words' && tab === 'words') {
|
||||
setWordsRefreshTrigger(prev => prev + 1)
|
||||
}
|
||||
// Обновляем список задач при возврате из экрана редактирования
|
||||
// Используем фоновую загрузку, чтобы не показывать индикатор загрузки
|
||||
if (activeTab === 'task-form' && tab === 'tasks') {
|
||||
fetchTasksData(true)
|
||||
}
|
||||
// Загрузка данных произойдет в useEffect при изменении activeTab
|
||||
}
|
||||
}
|
||||
@@ -393,7 +457,7 @@ function AppContent() {
|
||||
}, [activeTab])
|
||||
|
||||
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
|
||||
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config'
|
||||
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config' || activeTab === 'task-form'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen min-h-dvh">
|
||||
@@ -493,6 +557,28 @@ function AppContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs.tasks && (
|
||||
<div className={activeTab === 'tasks' ? 'block' : 'hidden'}>
|
||||
<TaskList
|
||||
onNavigate={handleNavigate}
|
||||
data={tasksData}
|
||||
loading={tasksLoading}
|
||||
backgroundLoading={tasksBackgroundLoading}
|
||||
onRefresh={(isBackground = false) => fetchTasksData(isBackground)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs['task-form'] && (
|
||||
<div className={activeTab === 'task-form' ? 'block' : 'hidden'}>
|
||||
<TaskForm
|
||||
key={tabParams.taskId || 'new'}
|
||||
onNavigate={handleNavigate}
|
||||
taskId={tabParams.taskId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs.profile && (
|
||||
<div className={activeTab === 'profile' ? 'block' : 'hidden'}>
|
||||
<Profile onNavigate={handleNavigate} />
|
||||
@@ -546,6 +632,25 @@ function AppContent() {
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('tasks')}
|
||||
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||
activeTab === 'tasks' || activeTab === 'task-form'
|
||||
? 'text-indigo-700 bg-white/50'
|
||||
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
||||
}`}
|
||||
title="Задачи"
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 11l3 3L22 4"></path>
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||
</svg>
|
||||
</span>
|
||||
{(activeTab === 'tasks' || activeTab === 'task-form') && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('profile')}
|
||||
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||
|
||||
193
play-life-web/src/components/TaskDetail.css
Normal file
193
play-life-web/src/components/TaskDetail.css
Normal file
@@ -0,0 +1,193 @@
|
||||
/* Модальное окно */
|
||||
.task-detail-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: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.task-detail-modal {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-detail-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.task-detail-close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.task-detail-close-button:hover {
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.task-detail-modal-content {
|
||||
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-detail-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-reward-message {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.375rem;
|
||||
border-left: 3px solid #6366f1;
|
||||
}
|
||||
|
||||
.reward-message-text {
|
||||
color: #374151;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.reward-message-text strong {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-subtasks {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.subtasks-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.subtask-item {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtask-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subtask-checkbox {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subtask-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.subtask-name {
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.subtask-reward-message {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: white;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.task-complete-section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.progression-input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progression-input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.progression-input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.complete-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(to right, #6366f1, #8b5cf6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
.complete-button.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.complete-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.complete-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error-message {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
206
play-life-web/src/components/TaskDetail.jsx
Normal file
206
play-life-web/src/components/TaskDetail.jsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import './TaskDetail.css'
|
||||
|
||||
const API_URL = '/api/tasks'
|
||||
|
||||
function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [taskDetail, setTaskDetail] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [selectedSubtasks, setSelectedSubtasks] = useState(new Set())
|
||||
const [progressionValue, setProgressionValue] = useState('')
|
||||
const [isCompleting, setIsCompleting] = useState(false)
|
||||
|
||||
const fetchTaskDetail = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await authFetch(`${API_URL}/${taskId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка загрузки задачи')
|
||||
}
|
||||
const data = await response.json()
|
||||
setTaskDetail(data)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
console.error('Error fetching task detail:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [taskId, authFetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (taskId) {
|
||||
fetchTaskDetail()
|
||||
} else {
|
||||
// Сбрасываем состояние при закрытии модального окна
|
||||
setTaskDetail(null)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSelectedSubtasks(new Set())
|
||||
setProgressionValue('')
|
||||
}
|
||||
}, [taskId, fetchTaskDetail])
|
||||
|
||||
const handleSubtaskToggle = (subtaskId) => {
|
||||
setSelectedSubtasks(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(subtaskId)) {
|
||||
newSet.delete(subtaskId)
|
||||
} else {
|
||||
newSet.add(subtaskId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!taskDetail) return
|
||||
|
||||
// Валидация: если progression_base != null, то value обязателен
|
||||
if (taskDetail.task.progression_base != null && !progressionValue.trim()) {
|
||||
alert('Поле "Значение" обязательно для задач с прогрессией')
|
||||
return
|
||||
}
|
||||
|
||||
setIsCompleting(true)
|
||||
try {
|
||||
const payload = {
|
||||
children_task_ids: Array.from(selectedSubtasks)
|
||||
}
|
||||
|
||||
if (taskDetail.task.progression_base != null && progressionValue.trim()) {
|
||||
payload.value = parseFloat(progressionValue)
|
||||
if (isNaN(payload.value)) {
|
||||
throw new Error('Неверное значение')
|
||||
}
|
||||
}
|
||||
|
||||
const response = await authFetch(`${API_URL}/${taskId}/complete`, {
|
||||
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)
|
||||
alert(err.message || 'Ошибка при выполнении задачи')
|
||||
} finally {
|
||||
setIsCompleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!taskId) return null
|
||||
|
||||
const { task, rewards, subtasks } = taskDetail || {}
|
||||
const hasProgression = task?.progression_base != null
|
||||
const canComplete = !hasProgression || (hasProgression && progressionValue.trim())
|
||||
|
||||
return (
|
||||
<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 : 'Задача'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="task-detail-close-button">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="task-detail-modal-content">
|
||||
{loading && (
|
||||
<div className="loading">Загрузка...</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && taskDetail && (
|
||||
<>
|
||||
{subtasks && subtasks.length > 0 && (
|
||||
<div className="task-subtasks">
|
||||
{subtasks.map((subtask) => {
|
||||
const subtaskName = subtask.task.name || 'Подзадача'
|
||||
return (
|
||||
<div key={subtask.task.id} className="subtask-item">
|
||||
<label className="subtask-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSubtasks.has(subtask.task.id)}
|
||||
onChange={() => handleSubtaskToggle(subtask.task.id)}
|
||||
className="subtask-checkbox"
|
||||
/>
|
||||
<div className="subtask-content">
|
||||
<div className="subtask-name">{subtaskName}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="task-complete-section">
|
||||
{hasProgression ? (
|
||||
<div className="progression-input-group">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={progressionValue}
|
||||
onChange={(e) => setProgressionValue(e.target.value)}
|
||||
placeholder={`Значение (~${task.progression_base})`}
|
||||
className="progression-input"
|
||||
/>
|
||||
{progressionValue.trim() && (
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
disabled={isCompleting}
|
||||
className="complete-button"
|
||||
>
|
||||
{isCompleting ? 'Выполнение...' : '✓'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
disabled={isCompleting || !canComplete}
|
||||
className="complete-button full-width"
|
||||
>
|
||||
{isCompleting ? 'Выполнение...' : 'Выполнить'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TaskDetail
|
||||
|
||||
448
play-life-web/src/components/TaskList.jsx
Normal file
448
play-life-web/src/components/TaskList.jsx
Normal file
@@ -0,0 +1,448 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import TaskDetail from './TaskDetail'
|
||||
import Toast from './Toast'
|
||||
import './TaskList.css'
|
||||
|
||||
const API_URL = '/api/tasks'
|
||||
|
||||
function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
const { authFetch } = useAuth()
|
||||
// Инициализируем tasks из data, если data есть, иначе пустой массив
|
||||
const [tasks, setTasks] = useState(() => data && Array.isArray(data) ? data : [])
|
||||
const [taskDetails, setTaskDetails] = useState({})
|
||||
const [loadingDetails, setLoadingDetails] = useState(false)
|
||||
const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null)
|
||||
const [isCompleting, setIsCompleting] = useState(false)
|
||||
const [expandedCompleted, setExpandedCompleted] = useState({})
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
// Для отслеживания изменений в списке задач (чтобы не перезагружать детали без необходимости)
|
||||
const lastTaskIdsRef = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setTasks(data)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
// Загрузка данных управляется из App.jsx через loadTabData
|
||||
// TaskList не инициирует загрузку самостоятельно
|
||||
|
||||
// Загружаем детали для всех задач
|
||||
// Оптимизация: загружаем только если список задач изменился (по id и last_completed_at)
|
||||
useEffect(() => {
|
||||
if (!tasks || tasks.length === 0) {
|
||||
setTaskDetails({})
|
||||
lastTaskIdsRef.current = ''
|
||||
return
|
||||
}
|
||||
|
||||
// Создаем ключ из id и last_completed_at всех задач
|
||||
const taskKey = tasks.map(t => `${t.id}:${t.last_completed_at || ''}`).sort().join(',')
|
||||
|
||||
// Если ключ не изменился, не перезагружаем детали
|
||||
if (taskKey === lastTaskIdsRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastTaskIdsRef.current = taskKey
|
||||
|
||||
const loadTaskDetails = async () => {
|
||||
// Не показываем индикатор загрузки если детали уже есть (фоновое обновление)
|
||||
const hasExistingDetails = Object.keys(taskDetails).length > 0
|
||||
if (!hasExistingDetails) {
|
||||
setLoadingDetails(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const detailPromises = tasks.map(async (task) => {
|
||||
try {
|
||||
const response = await authFetch(`${API_URL}/${task.id}`)
|
||||
if (response.ok) {
|
||||
const detail = await response.json()
|
||||
return { taskId: task.id, detail }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error loading task detail for ${task.id}:`, err)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const details = await Promise.all(detailPromises)
|
||||
const detailsMap = {}
|
||||
details.forEach(item => {
|
||||
if (item) {
|
||||
detailsMap[item.taskId] = item.detail
|
||||
}
|
||||
})
|
||||
setTaskDetails(detailsMap)
|
||||
} catch (err) {
|
||||
console.error('Error loading task details:', err)
|
||||
} finally {
|
||||
setLoadingDetails(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadTaskDetails()
|
||||
}, [tasks, authFetch])
|
||||
|
||||
const handleTaskClick = (task) => {
|
||||
onNavigate?.('task-form', { taskId: task.id })
|
||||
}
|
||||
|
||||
const handleCheckmarkClick = async (task, e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
const detail = taskDetails[task.id]
|
||||
const hasProgression = detail?.task?.progression_base != null
|
||||
const hasSubtasks = detail?.subtasks && detail.subtasks.length > 0
|
||||
|
||||
if (hasProgression || hasSubtasks) {
|
||||
// Открываем экран details
|
||||
setSelectedTaskForDetail(task.id)
|
||||
} else {
|
||||
// Отправляем задачу
|
||||
setIsCompleting(true)
|
||||
try {
|
||||
const response = await authFetch(`${API_URL}/${task.id}/complete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || 'Ошибка при выполнении задачи')
|
||||
}
|
||||
|
||||
// Показываем toast о выполнении задачи
|
||||
setToast({ message: 'Задача выполнена' })
|
||||
|
||||
// Обновляем список
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error completing task:', err)
|
||||
alert(err.message || 'Ошибка при выполнении задачи')
|
||||
} finally {
|
||||
setIsCompleting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseDetail = () => {
|
||||
setSelectedTaskForDetail(null)
|
||||
}
|
||||
|
||||
const handleAddClick = () => {
|
||||
onNavigate?.('task-form', { taskId: undefined })
|
||||
}
|
||||
|
||||
const toggleCompletedExpanded = (projectName) => {
|
||||
setExpandedCompleted(prev => ({
|
||||
...prev,
|
||||
[projectName]: !prev[projectName]
|
||||
}))
|
||||
}
|
||||
|
||||
// Получаем все проекты из наград задачи и подзадач
|
||||
const getTaskProjects = (task) => {
|
||||
const projects = new Set()
|
||||
const detail = taskDetails[task.id]
|
||||
|
||||
if (detail) {
|
||||
// Проекты из основной задачи
|
||||
if (detail.rewards) {
|
||||
detail.rewards.forEach(reward => {
|
||||
if (reward.project_name) {
|
||||
projects.add(reward.project_name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Проекты из подзадач
|
||||
if (detail.subtasks) {
|
||||
detail.subtasks.forEach(subtask => {
|
||||
if (subtask.rewards) {
|
||||
subtask.rewards.forEach(reward => {
|
||||
if (reward.project_name) {
|
||||
projects.add(reward.project_name)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(projects)
|
||||
}
|
||||
|
||||
// Функция для проверки, является ли период нулевым
|
||||
const isZeroPeriod = (intervalStr) => {
|
||||
if (!intervalStr) return false
|
||||
|
||||
const parts = intervalStr.trim().split(/\s+/)
|
||||
if (parts.length < 1) return false
|
||||
|
||||
const value = parseInt(parts[0], 10)
|
||||
return !isNaN(value) && value === 0
|
||||
}
|
||||
|
||||
// Функция для парсинга PostgreSQL INTERVAL и добавления к дате
|
||||
const addIntervalToDate = (date, intervalStr) => {
|
||||
if (!intervalStr) return null
|
||||
|
||||
const result = new Date(date)
|
||||
|
||||
// Парсим строку интервала (формат: "1 day", "2 hours", "3 months", etc.)
|
||||
const parts = intervalStr.trim().split(/\s+/)
|
||||
if (parts.length < 2) return null
|
||||
|
||||
const value = parseInt(parts[0], 10)
|
||||
if (isNaN(value)) return null
|
||||
|
||||
const unit = parts[1].toLowerCase()
|
||||
|
||||
switch (unit) {
|
||||
case 'minute':
|
||||
case 'minutes':
|
||||
result.setMinutes(result.getMinutes() + value)
|
||||
break
|
||||
case 'hour':
|
||||
case 'hours':
|
||||
result.setHours(result.getHours() + value)
|
||||
break
|
||||
case 'day':
|
||||
case 'days':
|
||||
result.setDate(result.getDate() + value)
|
||||
break
|
||||
case 'week':
|
||||
case 'weeks':
|
||||
result.setDate(result.getDate() + value * 7)
|
||||
break
|
||||
case 'month':
|
||||
case 'months':
|
||||
result.setMonth(result.getMonth() + value)
|
||||
break
|
||||
case 'year':
|
||||
case 'years':
|
||||
result.setFullYear(result.getFullYear() + value)
|
||||
break
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Группируем задачи по проектам
|
||||
const groupedTasks = useMemo(() => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const groups = {}
|
||||
|
||||
tasks.forEach(task => {
|
||||
const projects = getTaskProjects(task)
|
||||
|
||||
// Если у задачи нет проектов, добавляем в группу "Без проекта"
|
||||
if (projects.length === 0) {
|
||||
projects.push('Без проекта')
|
||||
}
|
||||
|
||||
// Определяем, в какую группу попадает задача
|
||||
let isCompleted = false
|
||||
|
||||
// Если у задачи период повторения = 0, она всегда в невыполненных
|
||||
if (task.repetition_period && isZeroPeriod(task.repetition_period)) {
|
||||
isCompleted = false
|
||||
} else if (task.repetition_period) {
|
||||
// Если есть repetition_period (и он не 0), проверяем логику повторения
|
||||
if (task.last_completed_at) {
|
||||
const lastCompleted = new Date(task.last_completed_at)
|
||||
const nextDueDate = addIntervalToDate(lastCompleted, task.repetition_period)
|
||||
|
||||
if (nextDueDate) {
|
||||
// Округляем до начала дня
|
||||
nextDueDate.setHours(0, 0, 0, 0)
|
||||
|
||||
// Если nextDueDate > today, то задача в выполненных
|
||||
isCompleted = nextDueDate.getTime() > today.getTime()
|
||||
} else {
|
||||
// Если не удалось распарсить интервал, используем старую логику
|
||||
const completedDate = new Date(task.last_completed_at)
|
||||
completedDate.setHours(0, 0, 0, 0)
|
||||
isCompleted = completedDate.getTime() === today.getTime()
|
||||
}
|
||||
} else {
|
||||
// Если нет last_completed_at, то в обычной группе
|
||||
isCompleted = false
|
||||
}
|
||||
} else {
|
||||
// Если repetition_period == null, используем старую логику
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
projects.forEach(projectName => {
|
||||
if (!groups[projectName]) {
|
||||
groups[projectName] = {
|
||||
notCompleted: [],
|
||||
completed: []
|
||||
}
|
||||
}
|
||||
|
||||
if (isCompleted) {
|
||||
groups[projectName].completed.push(task)
|
||||
} else {
|
||||
groups[projectName].notCompleted.push(task)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return groups
|
||||
}, [tasks, taskDetails])
|
||||
|
||||
const renderTaskItem = (task) => {
|
||||
const detail = taskDetails[task.id]
|
||||
const hasProgression = detail?.task?.progression_base != null
|
||||
const hasSubtasks = detail?.subtasks && detail.subtasks.length > 0
|
||||
const showDetailOnCheckmark = hasProgression || hasSubtasks
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="task-item"
|
||||
onClick={() => handleTaskClick(task)}
|
||||
>
|
||||
<div className="task-item-content">
|
||||
<div
|
||||
className={`task-checkmark ${showDetailOnCheckmark ? 'task-checkmark-detail' : ''}`}
|
||||
onClick={(e) => handleCheckmarkClick(task, e)}
|
||||
title={showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу'}
|
||||
>
|
||||
<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="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
|
||||
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="task-name">{task.name}</div>
|
||||
<div className="task-actions">
|
||||
<span className="task-completed-count">{task.completed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Показываем загрузку только если данных нет и это не фоновая загрузка
|
||||
// Проверяем наличие данных более надежно: либо в data, либо в tasks
|
||||
// Важно: проверяем оба источника данных, так как они могут обновляться асинхронно
|
||||
const hasDataInProps = data && Array.isArray(data) && data.length > 0
|
||||
const hasDataInState = tasks && Array.isArray(tasks) && tasks.length > 0
|
||||
const hasData = hasDataInProps || hasDataInState
|
||||
|
||||
// Показываем загрузку только если:
|
||||
// 1. Идет загрузка (loading = true)
|
||||
// 2. Это не фоновая загрузка (backgroundLoading = false)
|
||||
// 3. Данных нет (hasData = false)
|
||||
// Это предотвращает показ загрузки при переключении табов, когда данные уже есть
|
||||
if (loading && !backgroundLoading && !hasData) {
|
||||
return (
|
||||
<div className="task-list">
|
||||
<div className="loading">Загрузка...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const projectNames = Object.keys(groupedTasks).sort()
|
||||
|
||||
return (
|
||||
<div className="task-list">
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
onClose={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
<button onClick={handleAddClick} className="add-task-button">
|
||||
Добавить
|
||||
</button>
|
||||
|
||||
{loadingDetails && tasks.length > 0 && (
|
||||
<div className="loading-details">Загрузка деталей задач...</div>
|
||||
)}
|
||||
|
||||
{projectNames.length === 0 && !loading && tasks.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>Задач пока нет. Добавьте задачу через кнопку "Добавить".</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projectNames.map(projectName => {
|
||||
const group = groupedTasks[projectName]
|
||||
const hasCompleted = group.completed.length > 0
|
||||
const isExpanded = expandedCompleted[projectName]
|
||||
|
||||
return (
|
||||
<div key={projectName} className="project-group">
|
||||
<div className="project-group-header">
|
||||
<h3 className="project-group-title">{projectName}</h3>
|
||||
</div>
|
||||
|
||||
{group.notCompleted.length > 0 && (
|
||||
<div className="task-group">
|
||||
{group.notCompleted.map(renderTaskItem)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCompleted && (
|
||||
<div className="completed-section">
|
||||
<button
|
||||
className="completed-toggle"
|
||||
onClick={() => toggleCompletedExpanded(projectName)}
|
||||
>
|
||||
<span className="completed-toggle-icon">
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<span>Выполненные ({group.completed.length})</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="task-group completed-tasks">
|
||||
{group.completed.map(renderTaskItem)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.notCompleted.length === 0 && !hasCompleted && (
|
||||
<div className="empty-group">Нет задач в этой группе</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Модальное окно для деталей задачи */}
|
||||
{selectedTaskForDetail && (
|
||||
<TaskDetail
|
||||
taskId={selectedTaskForDetail}
|
||||
onClose={handleCloseDetail}
|
||||
onRefresh={onRefresh}
|
||||
onTaskCompleted={() => setToast({ message: 'Задача выполнена' })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TaskList
|
||||
|
||||
34
play-life-web/src/components/Toast.css
Normal file
34
play-life-web/src/components/Toast.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(100px);
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
padding: 1rem 1.5rem;
|
||||
min-width: 250px;
|
||||
max-width: 400px;
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-visible {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
color: #1f2937;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
30
play-life-web/src/components/Toast.jsx
Normal file
30
play-life-web/src/components/Toast.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import './Toast.css'
|
||||
|
||||
function Toast({ message, onClose, duration = 3000 }) {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
setTimeout(() => {
|
||||
onClose?.()
|
||||
}, 300) // Ждем завершения анимации
|
||||
}, duration)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [duration, onClose])
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div className={`toast ${isVisible ? 'toast-visible' : ''}`}>
|
||||
<div className="toast-content">
|
||||
<span className="toast-message">{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Toast
|
||||
|
||||
Reference in New Issue
Block a user