import React, { useState, useEffect, useRef } from 'react' import { useAuth } from './auth/AuthContext' import Toast from './Toast' import SubmitButton from './SubmitButton' import DeleteButton from './DeleteButton' 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, index) => ({ id: st.task.id, name: st.task.name || '', reward_message: st.task.reward_message || '', position: st.task.position !== undefined && st.task.position !== null ? st.task.position : index, 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: '', position: subtasks.length, 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) => { const newSubtasks = subtasks.filter((_, i) => i !== index) // Пересчитываем позиции после удаления newSubtasks.forEach((st, i) => { st.position = i }) setSubtasks(newSubtasks) } const handleMoveSubtaskUp = (index) => { if (index === 0) return // Нельзя переместить первый элемент вверх const newSubtasks = [...subtasks] const temp = newSubtasks[index] newSubtasks[index] = newSubtasks[index - 1] newSubtasks[index - 1] = temp // Обновляем позиции newSubtasks.forEach((st, i) => { st.position = i }) setSubtasks(newSubtasks) } const handleMoveSubtaskDown = (index) => { if (index === subtasks.length - 1) return // Нельзя переместить последний элемент вниз const newSubtasks = [...subtasks] const temp = newSubtasks[index] newSubtasks[index] = newSubtasks[index + 1] newSubtasks[index + 1] = temp // Обновляем позиции newSubtasks.forEach((st, i) => { st.position = i }) setSubtasks(newSubtasks) } 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, index) => ({ id: st.id || undefined, name: st.name.trim() || null, reward_message: st.reward_message.trim() || null, position: st.position !== undefined && st.position !== null ? st.position : index, 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() window.history.back() } 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 (