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 [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 [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') setRepetitionMode('after') 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) : '') // Проверяем, является ли задача бесконечной (оба поля = 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() 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 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 }))) // Загружаем подзадачи 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 // Находим все варианты плейсхолдеров: ${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 } } try { // Преобразуем период повторения в строку INTERVAL для PostgreSQL или repetition_date let repetitionPeriod = null let repetitionDate = null if (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 } } const payload = { name: name.trim(), reward_message: rewardMessage.trim() || null, progression_base: progressionBase ? parseFloat(progressionBase) : null, repetition_period: repetitionPeriod, repetition_date: repetitionDate, 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) } // Очищаем форму после успешного сохранения resetForm() // Возвращаемся к списку задач 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 (