2026-02-08 17:01:36 +03:00
|
|
|
|
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 [groupName, setGroupName] = useState('')
|
|
|
|
|
|
const [groupSuggestions, setGroupSuggestions] = 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()
|
|
|
|
|
|
}, [authFetch])
|
|
|
|
|
|
|
|
|
|
|
|
// Загрузка саджестов групп
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const loadGroupSuggestions = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await authFetch('/api/group-suggestions')
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
setGroupSuggestions(Array.isArray(data) ? data : [])
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Error loading group suggestions:', err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
loadGroupSuggestions()
|
|
|
|
|
|
}, [authFetch])
|
|
|
|
|
|
|
|
|
|
|
|
// Загрузка словарей для тестов
|
|
|
|
|
|
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([])
|
2026-02-09 14:16:57 +03:00
|
|
|
|
setGroupName('')
|
2026-02-08 17:01:36 +03:00
|
|
|
|
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) : '')
|
2026-02-09 14:16:57 +03:00
|
|
|
|
setGroupName(data.task.group_name ?? '')
|
|
|
|
|
|
|
2026-02-08 17:01:36 +03:00
|
|
|
|
// Проверяем, является ли задача бесконечной (оба поля = 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,
|
|
|
|
|
|
group_name: groupName.trim() || null,
|
|
|
|
|
|
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 {
|
2026-03-09 21:42:18 +03:00
|
|
|
|
console.log('[TaskForm] No returnTo, going back in history')
|
|
|
|
|
|
// Возврат назад по стеку истории (на список задач, желаний и т.д.)
|
|
|
|
|
|
window.history.back()
|
2026-02-08 17:01:36 +03:00
|
|
|
|
}
|
|
|
|
|
|
} 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 (
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="form-group">
|
|
|
|
|
|
<label htmlFor="group">Группа</label>
|
|
|
|
|
|
<GroupAutocomplete
|
|
|
|
|
|
suggestions={groupSuggestions}
|
|
|
|
|
|
value={groupName}
|
|
|
|
|
|
onChange={setGroupName}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Информация о связанном желании */}
|
|
|
|
|
|
{wishlistInfo && (
|
|
|
|
|
|
<div className="form-group">
|
|
|
|
|
|
<div className="wishlist-link-info">
|
|
|
|
|
|
<span className="wishlist-link-text">
|
|
|
|
|
|
Связана с желанием: <strong>{wishlistInfo.name}</strong>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div style={{ marginTop: '12px' }}>
|
|
|
|
|
|
<label htmlFor="reward_policy" style={{ display: 'block', marginBottom: '4px' }}>Политика награждения:</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>
|
|
|
|
|
|
</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">
|
|
|
|
|
|
<div className="subtask-position-controls">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => handleMoveSubtaskUp(index)}
|
|
|
|
|
|
className="move-subtask-button"
|
|
|
|
|
|
disabled={index === 0}
|
|
|
|
|
|
title="Переместить вверх"
|
|
|
|
|
|
>
|
|
|
|
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
|
|
|
|
<polyline points="18 15 12 9 6 15"></polyline>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => handleMoveSubtaskDown(index)}
|
|
|
|
|
|
className="move-subtask-button"
|
|
|
|
|
|
disabled={index === subtasks.length - 1}
|
|
|
|
|
|
title="Переместить вниз"
|
|
|
|
|
|
>
|
|
|
|
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
|
|
|
|
<polyline points="6 9 12 15 18 9"></polyline>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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) => {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<SubmitButton
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
loading={loading}
|
|
|
|
|
|
disabled={isDeleting}
|
|
|
|
|
|
>
|
|
|
|
|
|
Сохранить
|
|
|
|
|
|
</SubmitButton>
|
|
|
|
|
|
{taskId && (
|
|
|
|
|
|
<DeleteButton
|
|
|
|
|
|
onClick={handleDelete}
|
|
|
|
|
|
loading={isDeleting}
|
|
|
|
|
|
disabled={loading}
|
|
|
|
|
|
title="Удалить задачу"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
{toastMessage && (
|
|
|
|
|
|
<Toast
|
|
|
|
|
|
message={toastMessage.text}
|
|
|
|
|
|
type={toastMessage.type}
|
|
|
|
|
|
onClose={() => setToastMessage(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Компонент автодополнения для выбора группы
|
|
|
|
|
|
function GroupAutocomplete({ suggestions, value, onChange }) {
|
|
|
|
|
|
const [inputValue, setInputValue] = useState('')
|
|
|
|
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
|
|
|
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
|
|
|
|
const wrapperRef = useRef(null)
|
|
|
|
|
|
const inputRef = useRef(null)
|
|
|
|
|
|
|
|
|
|
|
|
// При изменении value - обновить inputValue
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setInputValue(value || '')
|
|
|
|
|
|
}, [value])
|
|
|
|
|
|
|
|
|
|
|
|
// Фильтрация саджестов
|
|
|
|
|
|
const filteredSuggestions = inputValue.trim()
|
|
|
|
|
|
? suggestions.filter(group =>
|
|
|
|
|
|
group.toLowerCase().includes(inputValue.toLowerCase())
|
|
|
|
|
|
)
|
|
|
|
|
|
: suggestions
|
|
|
|
|
|
|
|
|
|
|
|
// Закрытие при клике снаружи
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const handleClickOutside = (e) => {
|
|
|
|
|
|
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
|
|
|
|
|
|
setIsOpen(false)
|
|
|
|
|
|
// Восстанавливаем значение
|
|
|
|
|
|
setInputValue(value || '')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
|
|
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
|
|
|
|
}, [value])
|
|
|
|
|
|
|
|
|
|
|
|
const handleInputChange = (e) => {
|
|
|
|
|
|
const newValue = e.target.value
|
|
|
|
|
|
setInputValue(newValue)
|
|
|
|
|
|
setIsOpen(true)
|
|
|
|
|
|
setHighlightedIndex(-1)
|
|
|
|
|
|
onChange(newValue)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSelectGroup = (group) => {
|
|
|
|
|
|
onChange(group)
|
|
|
|
|
|
setInputValue(group)
|
|
|
|
|
|
setIsOpen(false)
|
|
|
|
|
|
setHighlightedIndex(-1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleKeyDown = (e) => {
|
|
|
|
|
|
if (!isOpen) {
|
|
|
|
|
|
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
|
|
|
|
|
setIsOpen(true)
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch (e.key) {
|
|
|
|
|
|
case 'ArrowDown':
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
setHighlightedIndex(prev =>
|
|
|
|
|
|
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
|
|
|
|
|
|
)
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'ArrowUp':
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
setHighlightedIndex(prev => prev > 0 ? prev - 1 : -1)
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'Enter':
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
if (highlightedIndex >= 0 && filteredSuggestions[highlightedIndex]) {
|
|
|
|
|
|
handleSelectGroup(filteredSuggestions[highlightedIndex])
|
|
|
|
|
|
}
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'Escape':
|
|
|
|
|
|
setIsOpen(false)
|
|
|
|
|
|
setInputValue(value || '')
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleFocus = () => {
|
|
|
|
|
|
setIsOpen(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="group-autocomplete" ref={wrapperRef}>
|
|
|
|
|
|
<div className="group-autocomplete-input-wrapper">
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={inputValue}
|
|
|
|
|
|
onChange={handleInputChange}
|
|
|
|
|
|
onFocus={handleFocus}
|
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
|
placeholder="Введите название группы..."
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{inputValue && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setInputValue('')
|
|
|
|
|
|
onChange('')
|
|
|
|
|
|
inputRef.current?.focus()
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="group-autocomplete-clear"
|
|
|
|
|
|
>
|
|
|
|
|
|
✕
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{isOpen && filteredSuggestions.length > 0 && (
|
|
|
|
|
|
<div className="group-autocomplete-dropdown">
|
|
|
|
|
|
{filteredSuggestions.map((group, index) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={group}
|
|
|
|
|
|
className={`group-autocomplete-item ${
|
|
|
|
|
|
highlightedIndex === index ? 'highlighted' : ''
|
|
|
|
|
|
}`}
|
|
|
|
|
|
onClick={() => handleSelectGroup(group)}
|
|
|
|
|
|
onMouseEnter={() => setHighlightedIndex(index)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{group}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default TaskForm
|
|
|
|
|
|
|