Files
play-life/play-life-web/src/components/TaskForm.jsx
poignatov a6065d7ff1
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 39s
fix: исправлен импорт TaskForm с явным расширением .jsx, версия 2.9.1
2026-01-04 19:42:29 +03:00

745 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
import './TaskForm.css'
const API_URL = '/api/tasks'
const PROJECTS_API_URL = '/projects'
function TaskForm({ onNavigate, taskId }) {
const { authFetch } = useAuth()
const [name, setName] = useState('')
const [progressionBase, setProgressionBase] = useState('')
const [rewardMessage, setRewardMessage] = useState('')
const [repetitionPeriodValue, setRepetitionPeriodValue] = useState('')
const [repetitionPeriodType, setRepetitionPeriodType] = useState('day')
const [rewards, setRewards] = useState([])
const [subtasks, setSubtasks] = useState([])
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [loadingTask, setLoadingTask] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const debounceTimer = useRef(null)
// Загрузка проектов для автокомплита
useEffect(() => {
const loadProjects = async () => {
try {
const response = await authFetch(PROJECTS_API_URL)
if (response.ok) {
const data = await response.json()
setProjects(Array.isArray(data) ? data : [])
}
} catch (err) {
console.error('Error loading projects:', err)
}
}
loadProjects()
}, [])
// Функция сброса формы
const resetForm = () => {
setName('')
setRewardMessage('')
setProgressionBase('')
setRepetitionPeriodValue('')
setRepetitionPeriodType('day')
setRewards([])
setSubtasks([])
setError('')
setLoadingTask(false)
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
debounceTimer.current = null
}
}
// Загрузка задачи при редактировании или сброс формы при создании новой
useEffect(() => {
if (taskId !== undefined && taskId !== null) {
loadTask()
} else {
// Сбрасываем форму при создании новой задачи
resetForm()
}
}, [taskId])
const loadTask = async () => {
setLoadingTask(true)
try {
const response = await authFetch(`${API_URL}/${taskId}`)
if (!response.ok) {
throw new Error('Ошибка загрузки задачи')
}
const data = await response.json()
setName(data.task.name)
setRewardMessage(data.task.reward_message || '')
setProgressionBase(data.task.progression_base ? String(data.task.progression_base) : '')
// Парсим repetition_period если он есть
if (data.task.repetition_period) {
const periodStr = data.task.repetition_period.trim()
console.log('Parsing repetition_period:', periodStr, 'Full task data:', data.task) // Отладка
// PostgreSQL может возвращать INTERVAL в разных форматах:
// - "1 day" / "1 days" / "10 days"
// - "02:00:00" (часы в формате времени)
// - "21 days" (недели преобразуются в дни)
// - "1 month" / "1 months" / "1 mon"
let parsed = false
// Пробуем парсить формат "N unit" или "N units"
// Используем более гибкий regex для парсинга
const match = periodStr.match(/^(\d+)\s+(minute|minutes|hour|hours|day|days|week|weeks|month|months|mon|year|years)/i)
if (match) {
const value = parseInt(match[1], 10)
const unit = match[2].toLowerCase()
console.log('Matched value:', value, 'unit:', unit) // Отладка
if (!isNaN(value) && value >= 0) {
// Преобразуем единицы PostgreSQL в наш формат
if (unit.startsWith('minute')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('minute')
parsed = true
} else if (unit.startsWith('hour')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('hour')
parsed = true
} else if (unit.startsWith('day')) {
// Может быть "1 day" или "10 days" или "21 days" (для недель)
// Если значение кратно 7, это может быть неделя
if (value % 7 === 0 && value >= 7) {
setRepetitionPeriodValue(String(value / 7))
setRepetitionPeriodType('week')
} else {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('day')
}
parsed = true
} else if (unit.startsWith('week')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('week')
parsed = true
} else if (unit.startsWith('month') || unit.startsWith('mon')) {
// PostgreSQL возвращает "1 mon" для месяцев
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('month')
parsed = true
} else if (unit.startsWith('year')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('year')
parsed = true
}
}
} else {
// Если regex не сработал, пробуем старый способ через split
const parts = periodStr.split(/\s+/)
if (parts.length >= 2) {
const value = parseInt(parts[0], 10)
if (!isNaN(value) && value >= 0) {
const unit = parts[1].toLowerCase()
console.log('Fallback parsing - value:', value, 'unit:', unit) // Отладка
if (unit.startsWith('minute')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('minute')
parsed = true
} else if (unit.startsWith('hour')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('hour')
parsed = true
} else if (unit.startsWith('day')) {
if (value % 7 === 0 && value >= 7) {
setRepetitionPeriodValue(String(value / 7))
setRepetitionPeriodType('week')
} else {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('day')
}
parsed = true
} else if (unit.startsWith('week')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('week')
parsed = true
} else if (unit.startsWith('month') || unit.startsWith('mon')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('month')
parsed = true
} else if (unit.startsWith('year')) {
setRepetitionPeriodValue(String(value))
setRepetitionPeriodType('year')
parsed = true
}
}
}
}
// Если не удалось распарсить, пробуем формат времени "HH:MM:SS"
if (!parsed && /^\d{1,2}:\d{2}:\d{2}/.test(periodStr)) {
const timeParts = periodStr.split(':')
if (timeParts.length >= 3) {
const hours = parseInt(timeParts[0], 10)
if (!isNaN(hours) && hours >= 0) {
setRepetitionPeriodValue(String(hours))
setRepetitionPeriodType('hour')
parsed = true
}
}
}
// Если не удалось распарсить, сбрасываем значения
if (!parsed) {
console.log('Failed to parse repetition_period:', periodStr) // Отладка
setRepetitionPeriodValue('')
setRepetitionPeriodType('day')
} else {
console.log('Successfully parsed repetition_period - value will be set') // Отладка
}
} else {
console.log('No repetition_period in task data') // Отладка
setRepetitionPeriodValue('')
setRepetitionPeriodType('day')
}
// Загружаем rewards
setRewards(data.rewards.map(r => ({
position: r.position,
project_name: r.project_name,
value: String(r.value),
use_progression: r.use_progression
})))
// Загружаем подзадачи
setSubtasks(data.subtasks.map(st => ({
id: st.task.id,
name: st.task.name || '',
reward_message: st.task.reward_message || '',
rewards: st.rewards.map(r => ({
position: r.position,
project_name: r.project_name,
value: String(r.value),
use_progression: r.use_progression
}))
})))
} catch (err) {
setError(err.message)
} finally {
setLoadingTask(false)
}
}
// Пересчет rewards при изменении reward_message (debounce)
useEffect(() => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
debounceTimer.current = setTimeout(() => {
const maxIndex = findMaxPlaceholderIndex(rewardMessage)
const currentRewards = [...rewards]
// Удаляем лишние rewards
while (currentRewards.length > maxIndex + 1) {
currentRewards.pop()
}
// Добавляем недостающие rewards
while (currentRewards.length < maxIndex + 1) {
currentRewards.push({
position: currentRewards.length,
project_name: '',
value: '0',
use_progression: false
})
}
setRewards(currentRewards)
}, 500)
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
}
}, [rewardMessage])
const findMaxPlaceholderIndex = (message) => {
if (!message) return -1
const matches = message.match(/\$\{(\d+)\}/g)
if (!matches) return -1
const indices = matches.map(m => parseInt(m.match(/\d+/)[0]))
return Math.max(...indices)
}
const handleRewardChange = (index, field, value) => {
const newRewards = [...rewards]
newRewards[index] = { ...newRewards[index], [field]: value }
setRewards(newRewards)
}
const handleRewardProgressionToggle = (index, checked) => {
const newRewards = [...rewards]
newRewards[index] = { ...newRewards[index], use_progression: checked }
setRewards(newRewards)
}
const handleAddSubtask = () => {
setSubtasks([...subtasks, {
id: null,
name: '',
reward_message: '',
rewards: []
}])
}
const handleSubtaskChange = (index, field, value) => {
const newSubtasks = [...subtasks]
newSubtasks[index] = { ...newSubtasks[index], [field]: value }
setSubtasks(newSubtasks)
}
const handleSubtaskRewardMessageChange = (index, value) => {
const newSubtasks = [...subtasks]
newSubtasks[index] = { ...newSubtasks[index], reward_message: value }
// Пересчитываем rewards для подзадачи
const maxIndex = findMaxPlaceholderIndex(value)
const currentRewards = newSubtasks[index].rewards || []
const newRewards = [...currentRewards]
while (newRewards.length < maxIndex + 1) {
newRewards.push({
position: newRewards.length,
project_name: '',
value: '0',
use_progression: false
})
}
while (newRewards.length > maxIndex + 1) {
newRewards.pop()
}
newSubtasks[index] = { ...newSubtasks[index], rewards: newRewards }
setSubtasks(newSubtasks)
}
const handleRemoveSubtask = (index) => {
setSubtasks(subtasks.filter((_, i) => i !== index))
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
// Валидация
if (!name.trim() || name.trim().length < 1) {
setError('Название задачи обязательно (минимум 1 символ)')
setLoading(false)
return
}
// Проверяем, что все rewards заполнены
for (const reward of rewards) {
if (!reward.project_name.trim()) {
setError('Все проекты в наградах должны быть заполнены')
setLoading(false)
return
}
}
try {
// Преобразуем период повторения в строку INTERVAL для PostgreSQL
let repetitionPeriod = null
if (repetitionPeriodValue && repetitionPeriodValue.trim() !== '') {
const value = parseInt(repetitionPeriodValue.trim(), 10)
if (!isNaN(value) && value >= 0) {
const typeMap = {
'minute': 'minute',
'hour': 'hour',
'day': 'day',
'week': 'week',
'month': 'month',
'year': 'year'
}
const unit = typeMap[repetitionPeriodType] || 'day'
repetitionPeriod = `${value} ${unit}`
console.log('Sending repetition_period:', repetitionPeriod, 'from value:', repetitionPeriodValue, 'type:', repetitionPeriodType)
}
} else {
console.log('No repetition_period to send (value:', repetitionPeriodValue, 'type:', repetitionPeriodType, ')')
}
const payload = {
name: name.trim(),
reward_message: rewardMessage.trim() || null,
progression_base: progressionBase ? parseFloat(progressionBase) : null,
repetition_period: repetitionPeriod,
rewards: rewards.map(r => ({
position: r.position,
project_name: r.project_name.trim(),
value: parseFloat(r.value) || 0,
use_progression: !!(progressionBase && r.use_progression)
})),
subtasks: subtasks.map(st => ({
id: st.id || undefined,
name: st.name.trim() || null,
reward_message: st.reward_message.trim() || null,
rewards: st.rewards.map(r => ({
position: r.position,
project_name: r.project_name.trim(),
value: parseFloat(r.value) || 0,
use_progression: !!(progressionBase && r.use_progression)
}))
}))
}
const url = taskId ? `${API_URL}/${taskId}` : API_URL
const method = taskId ? 'PUT' : 'POST'
const response = await authFetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
let errorMessage = 'Ошибка при сохранении задачи'
try {
const errorData = await response.json()
errorMessage = errorData.message || errorData.error || errorMessage
} catch (e) {
// Если не удалось распарсить JSON, используем текст ответа
const text = await response.text().catch(() => '')
if (text) {
errorMessage = text
}
}
throw new Error(errorMessage)
}
// Возвращаемся к списку задач
onNavigate?.('tasks')
} catch (err) {
setError(err.message)
console.error('Error saving task:', err)
} finally {
setLoading(false)
}
}
const handleCancel = () => {
resetForm()
onNavigate?.('tasks')
}
const handleDelete = async () => {
if (!taskId) return
if (!window.confirm(`Вы уверены, что хотите удалить задачу "${name}"?`)) {
return
}
setIsDeleting(true)
try {
const response = await authFetch(`${API_URL}/${taskId}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Ошибка при удалении задачи')
}
// Возвращаемся к списку задач
onNavigate?.('tasks')
} catch (err) {
console.error('Error deleting task:', err)
setError('Ошибка при удалении задачи')
setIsDeleting(false)
}
}
if (loadingTask) {
return (
<div className="task-form">
<div className="loading">Загрузка...</div>
</div>
)
}
return (
<div className="task-form">
<button className="close-x-button" onClick={handleCancel}>
</button>
<h2>{taskId ? 'Редактировать задачу' : 'Новая задача'}</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Название задачи *</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
minLength={1}
className="form-input"
/>
</div>
<div className="form-group">
<label htmlFor="progression_base">Прогрессия</label>
<input
id="progression_base"
type="number"
step="any"
value={progressionBase}
onChange={(e) => setProgressionBase(e.target.value)}
placeholder="Базовое значение"
className="form-input"
/>
<small style={{ color: '#666', fontSize: '0.9em' }}>
Оставьте пустым, если прогрессия не используется
</small>
</div>
<div className="form-group">
<label htmlFor="repetition_period">Период повторения</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
id="repetition_period"
type="number"
min="0"
value={repetitionPeriodValue}
onChange={(e) => setRepetitionPeriodValue(e.target.value)}
placeholder="Число"
className="form-input"
style={{ flex: '1' }}
/>
{repetitionPeriodValue && repetitionPeriodValue.trim() !== '' && parseInt(repetitionPeriodValue.trim(), 10) !== 0 && (
<select
value={repetitionPeriodType}
onChange={(e) => setRepetitionPeriodType(e.target.value)}
className="form-input"
style={{ width: '120px' }}
>
<option value="minute">Минута</option>
<option value="hour">Час</option>
<option value="day">День</option>
<option value="week">Неделя</option>
<option value="month">Месяц</option>
<option value="year">Год</option>
</select>
)}
</div>
<small style={{ color: '#666', fontSize: '0.9em' }}>
Оставьте пустым, если задача не повторяется. Введите 0, если задача никогда не переносится в выполненные.
</small>
</div>
<div className="form-group">
<label htmlFor="reward_message">Сообщение награды</label>
<textarea
id="reward_message"
value={rewardMessage}
onChange={(e) => setRewardMessage(e.target.value)}
placeholder="Используйте ${0}, ${1} для указания проектов"
className="form-textarea"
rows={3}
/>
{rewards.length > 0 && (
<div className="rewards-container">
{rewards.map((reward, index) => (
<div key={index} className="reward-item">
<span className="reward-number">{index}</span>
<input
type="text"
value={reward.project_name}
onChange={(e) => handleRewardChange(index, 'project_name', e.target.value)}
placeholder="Проект"
className="form-input reward-project-input"
list={`projects-list-${index}`}
/>
<datalist id={`projects-list-${index}`}>
{projects.map(p => (
<option key={p.project_id} value={p.project_name} />
))}
</datalist>
<input
type="number"
step="any"
value={reward.value}
onChange={(e) => handleRewardChange(index, 'value', e.target.value)}
placeholder="Score"
className="form-input reward-score-input"
/>
{progressionBase && (
<button
type="button"
tabIndex={0}
className={`progression-button ${reward.use_progression ? 'progression-button-filled' : 'progression-button-outlined'}`}
onMouseDown={(e) => {
e.preventDefault()
handleRewardProgressionToggle(index, !reward.use_progression)
}}
title={reward.use_progression ? 'Отключить прогрессию' : 'Включить прогрессию'}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
</button>
)}
</div>
))}
</div>
)}
</div>
<div className="form-group">
<div className="subtasks-header">
<label>Подзадачи</label>
<button type="button" onClick={handleAddSubtask} className="add-subtask-button" title="Добавить подзадачу">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
{subtasks.map((subtask, index) => (
<div key={index} className="subtask-form-item">
<div className="subtask-header-row">
<input
type="text"
value={subtask.name}
onChange={(e) => handleSubtaskChange(index, 'name', e.target.value)}
placeholder="Название подзадачи"
className="form-input subtask-name-input"
/>
<button
type="button"
onClick={() => handleRemoveSubtask(index)}
className="remove-subtask-button"
title="Удалить подзадачу"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
<textarea
value={subtask.reward_message}
onChange={(e) => handleSubtaskRewardMessageChange(index, e.target.value)}
placeholder="Сообщение награды (опционально)"
className="form-textarea"
rows={2}
/>
{subtask.rewards && subtask.rewards.length > 0 && (
<div className="subtask-rewards">
{subtask.rewards.map((reward, rIndex) => (
<div key={rIndex} className="reward-item">
<span className="reward-number">{rIndex}</span>
<input
type="text"
value={reward.project_name}
onChange={(e) => {
const newSubtasks = [...subtasks]
newSubtasks[index].rewards[rIndex].project_name = e.target.value
setSubtasks(newSubtasks)
}}
placeholder="Проект"
className="form-input reward-project-input"
list={`subtask-projects-${index}-${rIndex}`}
/>
<datalist id={`subtask-projects-${index}-${rIndex}`}>
{projects.map(p => (
<option key={p.project_id} value={p.project_name} />
))}
</datalist>
<input
type="number"
step="any"
value={reward.value}
onChange={(e) => {
const newSubtasks = [...subtasks]
newSubtasks[index].rewards[rIndex].value = e.target.value
setSubtasks(newSubtasks)
}}
placeholder="Score"
className="form-input reward-score-input"
/>
{progressionBase && (
<button
type="button"
tabIndex={0}
className={`progression-button progression-button-subtask ${reward.use_progression ? 'progression-button-filled' : 'progression-button-outlined'}`}
onMouseDown={(e) => {
e.preventDefault()
const newSubtasks = [...subtasks]
newSubtasks[index].rewards[rIndex].use_progression = !newSubtasks[index].rewards[rIndex].use_progression
setSubtasks(newSubtasks)
}}
title={reward.use_progression ? 'Отключить прогрессию' : 'Включить прогрессию'}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
</button>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
{error && <div className="error-message">{error}</div>}
<div className="form-actions">
<button type="submit" disabled={loading || isDeleting} className="submit-button">
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
{taskId && (
<button
type="button"
onClick={handleDelete}
className="delete-button"
disabled={isDeleting || loading}
title="Удалить задачу"
>
{isDeleting ? (
<span>...</span>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
)}
</button>
)}
</div>
</form>
</div>
)
}
export default TaskForm