Files
play-life/play-life-web/src/components/TaskForm.jsx
poignatov d0d1cbd8cb
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
Исправление сохранения reward_policy для задач-желаний
2026-01-21 18:57:47 +03:00

1213 lines
52 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 Toast from './Toast'
import './TaskForm.css'
const API_URL = '/api/tasks'
const PROJECTS_API_URL = '/projects'
function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = false, returnTo, returnWishlistId }) {
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 [repetitionMode, setRepetitionMode] = useState('after') // 'after' = Через, 'each' = Каждое
const [rewards, setRewards] = useState([])
const [subtasks, setSubtasks] = useState([])
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('') // Только для валидации
const [toastMessage, setToastMessage] = useState(null)
const [loadingTask, setLoadingTask] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании
const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи
const [rewardPolicy, setRewardPolicy] = useState('personal') // Политика награждения: 'personal' или 'general'
// Test-specific state
const [isTest, setIsTest] = useState(isTestFromProps)
const [wordsCount, setWordsCount] = useState('10')
const [maxCards, setMaxCards] = useState('')
const [selectedDictionaryIDs, setSelectedDictionaryIDs] = useState([])
const [availableDictionaries, setAvailableDictionaries] = useState([])
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()
}, [])
// Загрузка словарей для тестов
useEffect(() => {
const loadDictionaries = async () => {
try {
const response = await authFetch('/api/test-configs-and-dictionaries')
if (response.ok) {
const data = await response.json()
setAvailableDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
}
} catch (err) {
console.error('Error loading dictionaries:', err)
}
}
loadDictionaries()
}, [])
// Функция сброса формы
const resetForm = () => {
setName('')
setRewardMessage('')
setProgressionBase('')
setRepetitionPeriodValue('')
setRepetitionPeriodType('day')
setRepetitionMode('after')
setRewards([])
setSubtasks([])
setError('')
setLoadingTask(false)
// Reset test-specific fields
setIsTest(isTestFromProps)
setWordsCount('10')
setMaxCards('')
setSelectedDictionaryIDs([])
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
debounceTimer.current = null
}
}
// Загрузка задачи при редактировании или сброс формы при создании новой
useEffect(() => {
if (taskId !== undefined && taskId !== null) {
loadTask()
} else {
// Сбрасываем форму при создании новой задачи
resetForm()
if (wishlistId) {
// Преобразуем wishlistId в число
const wishlistIdNum = typeof wishlistId === 'string' ? parseInt(wishlistId, 10) : wishlistId
setCurrentWishlistId(wishlistIdNum)
// Загружаем данные желания здесь, чтобы они не сбросились
const loadWishlistData = async () => {
try {
const response = await authFetch(`/api/wishlist/${wishlistIdNum}`)
if (response.ok) {
const data = await response.json()
setWishlistInfo({ id: data.id, name: data.name })
// Предзаполняем название задачи названием желания
if (data.name) {
setName(data.name)
}
// Предзаполняем сообщение награды
if (data.name) {
setRewardMessage(`Выполнить желание: ${data.name}`)
}
}
} catch (err) {
console.error('Error loading wishlist:', err)
}
}
loadWishlistData()
} else {
setCurrentWishlistId(null)
setWishlistInfo(null)
setRewardPolicy('personal') // Сбрасываем при отвязке
}
}
}, [taskId, wishlistId, authFetch])
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) : '')
// Проверяем, является ли задача бесконечной (оба поля = 0)
const periodStr = data.task.repetition_period ? data.task.repetition_period.trim() : ''
const dateStr = data.task.repetition_date ? data.task.repetition_date.trim() : ''
const isPeriodZero = periodStr && (periodStr === '0 day' || periodStr.startsWith('0 '))
const isDateZero = dateStr && (dateStr === '0 week' || dateStr.startsWith('0 '))
const isInfinite = isPeriodZero && isDateZero
if (isInfinite) {
// Бесконечная задача: показываем 0 в форме
setRepetitionPeriodValue('0')
setRepetitionPeriodType('day')
setRepetitionMode('after')
console.log('Loading infinite task: both repetition_period and repetition_date are 0')
} else if (data.task.repetition_date) {
// Парсим repetition_date если он есть (приоритет над repetition_period)
console.log('Parsing repetition_date:', dateStr) // Отладка
// Формат: "N unit" где unit = week, month, year
// или "MM-DD year" для конкретной даты в году
const match = dateStr.match(/^(\d+(?:-\d+)?)\s+(week|month|year)/i)
if (match) {
const value = match[1]
const unit = match[2].toLowerCase()
setRepetitionPeriodValue(value)
setRepetitionPeriodType(unit)
setRepetitionMode('each')
} else {
console.log('Failed to parse repetition_date:', dateStr)
setRepetitionPeriodValue('')
setRepetitionPeriodType('week')
setRepetitionMode('each')
}
} else if (data.task.repetition_period) {
// Парсим repetition_period если он есть
setRepetitionMode('after')
const periodStr = data.task.repetition_period.trim()
// 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()
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('No repetition_period or repetition_date in task data') // Отладка
setRepetitionPeriodValue('')
setRepetitionPeriodType('day')
setRepetitionMode('after')
}
// Загружаем rewards
setRewards(data.rewards.map(r => ({
position: r.position,
project_name: r.project_name,
value: String(r.value),
use_progression: r.use_progression
})))
// Загружаем подзадачи (только если задача не является тестом)
if (data.task.config_id) {
// Для задач-тестов не загружаем подзадачи
setSubtasks([])
} else {
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
}))
})))
}
// Загружаем информацию о связанном желании, если есть
if (data.task.wishlist_id) {
setCurrentWishlistId(data.task.wishlist_id)
try {
const wishlistResponse = await authFetch(`/api/wishlist/${data.task.wishlist_id}`)
if (wishlistResponse.ok) {
const wishlistData = await wishlistResponse.json()
setWishlistInfo({ id: wishlistData.id, name: wishlistData.name })
// Если задача привязана к желанию, очищаем поля повторения и прогрессии
setRepetitionPeriodValue('')
setRepetitionPeriodType('day')
setRepetitionMode('after')
setProgressionBase('')
}
} catch (err) {
console.error('Error loading wishlist info:', err)
}
// Загружаем политику награждения
if (data.task.reward_policy) {
setRewardPolicy(data.task.reward_policy)
} else {
setRewardPolicy('personal') // Значение по умолчанию
}
} else {
setCurrentWishlistId(null)
setWishlistInfo(null)
setRewardPolicy('personal') // Сбрасываем при отвязке
}
// Загружаем информацию о тесте, если есть config_id
if (data.task.config_id) {
setIsTest(true)
// Данные теста приходят прямо в ответе getTaskDetail
if (data.words_count) {
setWordsCount(String(data.words_count))
}
if (data.max_cards) {
setMaxCards(String(data.max_cards))
}
if (data.dictionary_ids && Array.isArray(data.dictionary_ids)) {
setSelectedDictionaryIDs(data.dictionary_ids)
}
// Тесты не могут иметь прогрессию
setProgressionBase('')
// Тесты не могут иметь подзадачи - очищаем их
setSubtasks([])
} else {
setIsTest(false)
setWordsCount('10')
setMaxCards('')
setSelectedDictionaryIDs([])
}
} catch (err) {
setError(err.message)
} finally {
setLoadingTask(false)
}
}
// Очистка подзадач при переключении задачи в режим теста
useEffect(() => {
if (isTest && subtasks.length > 0) {
setSubtasks([])
}
}, [isTest])
// Пересчет 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
// Находим все варианты плейсхолдеров: ${0}, $0, но не \$0
const indices = []
// Ищем ${N}
const matchesCurly = message.match(/\$\{(\d+)\}/g) || []
matchesCurly.forEach(match => {
const numMatch = match.match(/\d+/)
if (numMatch) {
indices.push(parseInt(numMatch[0]))
}
})
// Ищем $N (но не \$N)
// Используем глобальный поиск и проверяем, что перед $ нет обратного слэша
let searchIndex = 0
while (true) {
const index = message.indexOf('$', searchIndex)
if (index === -1) break
// Проверяем, что перед $ нет обратного слэша
if (index === 0 || message[index - 1] !== '\\') {
// Проверяем, что после $ идет цифра
const afterDollar = message.substring(index + 1)
const digitMatch = afterDollar.match(/^(\d+)/)
if (digitMatch) {
// Проверяем, что после цифры не идет еще одна цифра (чтобы не захватить $10 при поиске $1)
const num = parseInt(digitMatch[0])
indices.push(num)
}
}
searchIndex = index + 1
}
return indices.length > 0 ? Math.max(...indices) : -1
}
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
}
}
// Проверяем, что задача с привязанным желанием не может быть периодической
const isLinkedToWishlist = wishlistInfo !== null || (taskId && currentWishlistId)
if (isLinkedToWishlist && repetitionPeriodValue && repetitionPeriodValue.trim() !== '') {
const value = parseInt(repetitionPeriodValue.trim(), 10)
if (!isNaN(value) && value !== 0) {
setError('Задачи, привязанные к желанию, не могут быть периодическими')
setLoading(false)
return
}
}
// Проверяем, что задача с привязанным желанием не может иметь прогрессию
if (isLinkedToWishlist && progressionBase && progressionBase.trim() !== '') {
setError('Задачи, привязанные к желанию, не могут иметь прогрессию')
setLoading(false)
return
}
try {
// Преобразуем период повторения в строку INTERVAL для PostgreSQL или repetition_date
let repetitionPeriod = null
let repetitionDate = null
// Если задача привязана к желанию, не устанавливаем повторения
if (!isLinkedToWishlist && repetitionPeriodValue && repetitionPeriodValue.trim() !== '') {
const valueStr = repetitionPeriodValue.trim()
const value = parseInt(valueStr, 10)
// Проверяем, является ли значение нулевым (бесконечная задача)
const isZero = !isNaN(value) && value === 0
if (isZero) {
// Бесконечная задача: устанавливаем оба поля в 0
// Для repetition_period используем "0 day"
repetitionPeriod = '0 day'
// Для repetition_date используем "0 week" (можно использовать любой тип, но week - наиболее универсальный)
repetitionDate = '0 week'
console.log('Creating infinite task: repetition_period=0 day, repetition_date=0 week')
} else if (repetitionMode === 'each') {
// Режим "Каждое" - сохраняем как repetition_date
// Формат: "N unit" где unit = week, month, year
repetitionDate = `${valueStr} ${repetitionPeriodType}`
repetitionPeriod = null // Убеждаемся, что repetition_period = null
console.log('Sending repetition_date:', repetitionDate)
} else {
// Режим "Через" - сохраняем как repetition_period (INTERVAL)
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}`
repetitionDate = null // Убеждаемся, что repetition_date = null
console.log('Sending repetition_period:', repetitionPeriod, 'from value:', repetitionPeriodValue, 'type:', repetitionPeriodType)
}
}
} else {
console.log('No repetition to send (value:', repetitionPeriodValue, 'type:', repetitionPeriodType, 'mode:', repetitionMode, ')')
}
// Валидация: если repetition_period != null, то repetition_date == null и наоборот, кроме случая когда они оба == 0
if (repetitionPeriod && repetitionDate) {
const isPeriodZero = repetitionPeriod.trim() === '0 day' || repetitionPeriod.trim().startsWith('0 ')
const isDateZero = repetitionDate.trim() === '0 week' || repetitionDate.trim().startsWith('0 ')
if (!isPeriodZero || !isDateZero) {
setError('Нельзя одновременно использовать repetition_period и repetition_date, кроме случая бесконечной задачи (оба = 0)')
setLoading(false)
return
}
}
// Валидация для тестов
if (isTest) {
const wordsCountNum = parseInt(wordsCount, 10)
if (isNaN(wordsCountNum) || wordsCountNum < 1) {
setError('Количество слов должно быть минимум 1')
setLoading(false)
return
}
if (selectedDictionaryIDs.length === 0) {
setError('Выберите хотя бы один словарь')
setLoading(false)
return
}
}
const payload = {
name: name.trim(),
reward_message: rewardMessage.trim() || null,
// Тесты и задачи с желанием не могут иметь прогрессию
progression_base: (isLinkedToWishlist || isTest) ? null : (progressionBase ? parseFloat(progressionBase) : null),
repetition_period: repetitionPeriod,
repetition_date: repetitionDate,
// При создании: отправляем currentWishlistId если указан (уже число)
// При редактировании: отправляем null только если была привязка (currentWishlistId) и пользователь отвязал (!wishlistInfo)
// Если не было привязки или привязка осталась - не отправляем поле (undefined)
wishlist_id: taskId
? currentWishlistId // При редактировании сохраняем текущую привязку к желанию
: (currentWishlistId || undefined),
// Отправляем reward_policy если задача связана с желанием
// Проверяем currentWishlistId или wishlistInfo, так как currentWishlistId устанавливается при загрузке задачи
reward_policy: (wishlistInfo || currentWishlistId) ? rewardPolicy : undefined,
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: isTest ? [] : 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)
}))
})),
// Test-specific fields
is_test: isTest,
words_count: isTest ? parseInt(wordsCount, 10) : undefined,
max_cards: isTest && maxCards ? parseInt(maxCards, 10) : undefined,
dictionary_ids: isTest ? selectedDictionaryIDs : undefined
}
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)
}
// Получаем сохранённую задачу из ответа
const savedTask = await response.json()
// Сервер возвращает Task напрямую, поэтому используем savedTask.id
const newTaskId = savedTask.id
console.log('[TaskForm] Task saved, returnTo:', returnTo, 'returnWishlistId:', returnWishlistId, 'newTaskId:', newTaskId)
// Очищаем форму после успешного сохранения
resetForm()
// Если был returnTo, возвращаемся на форму желания с ID новой задачи
if (returnTo === 'wishlist-form') {
console.log('[TaskForm] Navigating back to wishlist-form with newTaskId:', newTaskId)
onNavigate?.(returnTo, {
wishlistId: returnWishlistId,
newTaskId: newTaskId,
})
} else {
console.log('[TaskForm] No returnTo, navigating to tasks')
// Стандартное поведение - возврат к списку задач
onNavigate?.('tasks')
}
} catch (err) {
setToastMessage({ text: err.message || 'Ошибка при сохранении задачи', type: 'error' })
console.error('Error saving task:', err)
} finally {
setLoading(false)
}
}
const handleUnlinkWishlist = () => {
if (window.confirm('Отвязать задачу от желания?')) {
setCurrentWishlistId(null)
setWishlistInfo(null)
}
}
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)
setToastMessage({ text: err.message || 'Ошибка при удалении задачи', type: 'error' })
setIsDeleting(false)
}
}
return (
<div className="task-form">
<button className="close-x-button" onClick={handleCancel}>
</button>
{loadingTask ? (
<div className="fixed inset-0 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
) : (
<>
<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>
{/* Информация о связанном желании */}
{wishlistInfo && (
<div className="form-group">
<div className="wishlist-link-info">
<span className="wishlist-link-text">
Связана с желанием: <strong>{wishlistInfo.name}</strong>
</span>
</div>
<div className="form-group" style={{ marginTop: '12px' }}>
<label htmlFor="reward_policy">Политика награждения:</label>
<select
id="reward_policy"
value={rewardPolicy}
onChange={(e) => setRewardPolicy(e.target.value)}
className="form-input"
>
<option value="personal">Личная</option>
<option value="general">Общая</option>
</select>
<small style={{ color: '#666', fontSize: '0.9em', display: 'block', marginTop: '4px' }}>
{rewardPolicy === 'personal'
? 'Задача выполняется только если вы сами завершили желание. Если другой пользователь завершит желание, задача будет удалена.'
: 'Задача выполняется если кто-либо (неважно кто) отметил желание завершённым.'}
</small>
</div>
</div>
)}
{!isTest && !wishlistInfo && (
<div className="form-group">
<label htmlFor="progression_base">Прогрессия</label>
<input
id="progression_base"
type="number"
step="any"
value={progressionBase}
onChange={(e) => {
if (!wishlistInfo) {
setProgressionBase(e.target.value)
}
}}
placeholder="Базовое значение"
className="form-input"
disabled={wishlistInfo !== null}
/>
<small style={{ color: wishlistInfo ? '#e74c3c' : '#666', fontSize: '0.9em' }}>
{wishlistInfo ? 'Задачи, привязанные к желанию, не могут иметь прогрессию' : 'Оставьте пустым, если прогрессия не используется'}
</small>
</div>
)}
{/* Test-specific fields */}
{isTest && (
<div className="form-group test-config-section">
<label>Настройки теста</label>
<div className="test-config-fields">
<div className="test-field-group">
<label htmlFor="words_count">Количество слов *</label>
<input
id="words_count"
type="number"
min="1"
value={wordsCount}
onChange={(e) => setWordsCount(e.target.value)}
className="form-input"
required
/>
</div>
<div className="test-field-group">
<label htmlFor="max_cards">Макс. карточек</label>
<input
id="max_cards"
type="number"
min="1"
value={maxCards}
onChange={(e) => setMaxCards(e.target.value)}
placeholder="Без ограничения"
className="form-input"
/>
</div>
</div>
<div className="test-dictionaries-section">
<label>Словари *</label>
<div className="test-dictionaries-list">
{availableDictionaries.map(dict => (
<label key={dict.id} className="test-dictionary-item">
<input
type="checkbox"
checked={selectedDictionaryIDs.includes(dict.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedDictionaryIDs([...selectedDictionaryIDs, dict.id])
} else {
setSelectedDictionaryIDs(selectedDictionaryIDs.filter(id => id !== dict.id))
}
}}
/>
<span className="test-dictionary-name">{dict.name}</span>
<span className="test-dictionary-count">({dict.wordsCount} слов)</span>
</label>
))}
{availableDictionaries.length === 0 && (
<div className="test-no-dictionaries">
Нет доступных словарей. Создайте словарь в разделе "Словари".
</div>
)}
</div>
</div>
</div>
)}
{!wishlistInfo && (
<div className="form-group">
<label htmlFor="repetition_period">Повторения</label>
{(() => {
const isLinkedToWishlist = wishlistInfo !== null
const hasValidValue = repetitionPeriodValue && repetitionPeriodValue.trim() !== '' && parseInt(repetitionPeriodValue.trim(), 10) !== 0
const isEachMode = hasValidValue && repetitionMode === 'each'
const isYearType = isEachMode && repetitionPeriodType === 'year'
return (
<>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{hasValidValue && !isLinkedToWishlist && (
<select
value={repetitionMode}
onChange={(e) => {
setRepetitionMode(e.target.value)
// При переключении режима устанавливаем подходящий тип
if (e.target.value === 'each') {
// Для режима "Каждое" только week, month, year
if (!['week', 'month', 'year'].includes(repetitionPeriodType)) {
setRepetitionPeriodType('week')
}
}
}}
className="form-input"
style={{ width: '100px' }}
>
<option value="after">Через</option>
<option value="each">Каждое</option>
</select>
)}
<input
id="repetition_period"
type={isYearType ? 'text' : 'number'}
min="0"
value={repetitionPeriodValue}
onChange={(e) => {
if (!isLinkedToWishlist) {
setRepetitionPeriodValue(e.target.value)
}
}}
placeholder={isYearType ? 'ММ-ДД' : 'Число'}
className="form-input"
style={{ flex: '1' }}
disabled={isLinkedToWishlist}
/>
{hasValidValue && !isLinkedToWishlist && (
<select
value={repetitionPeriodType}
onChange={(e) => setRepetitionPeriodType(e.target.value)}
className="form-input"
style={{ width: '120px' }}
>
{repetitionMode === 'after' ? (
<>
<option value="minute">Минута</option>
<option value="hour">Час</option>
<option value="day">День</option>
<option value="week">Неделя</option>
<option value="month">Месяц</option>
<option value="year">Год</option>
</>
) : (
<>
<option value="week">Неделя</option>
<option value="month">Месяц</option>
<option value="year">Год</option>
</>
)}
</select>
)}
</div>
<small style={{ color: '#666', fontSize: '0.9em' }}>
{isLinkedToWishlist ? (
<span style={{ color: '#e74c3c' }}>Задачи, привязанные к желанию, не могут быть периодическими</span>
) : isEachMode ? (
repetitionPeriodType === 'week' ? 'Номер дня недели (1-7, где 1 = понедельник)' :
repetitionPeriodType === 'month' ? 'Номер дня месяца (1-31)' :
'Дата в формате ММ-ДД (например, 02-01 для 1 февраля)'
) : (
'Оставьте пустым, если задача не повторяется. Введите 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}, $0 для указания проектов (\\$0 для экранирования)"
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>
{!isTest && (
<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 && (error.includes('обязательно') || error.includes('должны быть заполнены') || error.includes('нельзя одновременно')) && (
<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>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</>
)}
)}
</div>
)
}
export default TaskForm