Добавлена связь задач с желаниями
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
This commit is contained in:
@@ -213,6 +213,7 @@ type Task struct {
|
|||||||
ProgressionBase *float64 `json:"progression_base,omitempty"`
|
ProgressionBase *float64 `json:"progression_base,omitempty"`
|
||||||
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
||||||
RepetitionDate *string `json:"repetition_date,omitempty"`
|
RepetitionDate *string `json:"repetition_date,omitempty"`
|
||||||
|
WishlistID *int `json:"wishlist_id,omitempty"`
|
||||||
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
|
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
|
||||||
ProjectNames []string `json:"project_names"`
|
ProjectNames []string `json:"project_names"`
|
||||||
SubtasksCount int `json:"subtasks_count"`
|
SubtasksCount int `json:"subtasks_count"`
|
||||||
@@ -258,6 +259,7 @@ type TaskRequest struct {
|
|||||||
RewardMessage *string `json:"reward_message,omitempty"`
|
RewardMessage *string `json:"reward_message,omitempty"`
|
||||||
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
||||||
RepetitionDate *string `json:"repetition_date,omitempty"`
|
RepetitionDate *string `json:"repetition_date,omitempty"`
|
||||||
|
WishlistID *int `json:"wishlist_id,omitempty"`
|
||||||
Rewards []RewardRequest `json:"rewards,omitempty"`
|
Rewards []RewardRequest `json:"rewards,omitempty"`
|
||||||
Subtasks []SubtaskRequest `json:"subtasks,omitempty"`
|
Subtasks []SubtaskRequest `json:"subtasks,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -275,6 +277,13 @@ type PostponeTaskRequest struct {
|
|||||||
// Wishlist structures
|
// Wishlist structures
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
type LinkedTask struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Completed int `json:"completed"`
|
||||||
|
NextShowAt *string `json:"next_show_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type WishlistItem struct {
|
type WishlistItem struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -286,6 +295,7 @@ type WishlistItem struct {
|
|||||||
FirstLockedCondition *UnlockConditionDisplay `json:"first_locked_condition,omitempty"`
|
FirstLockedCondition *UnlockConditionDisplay `json:"first_locked_condition,omitempty"`
|
||||||
MoreLockedConditions int `json:"more_locked_conditions,omitempty"`
|
MoreLockedConditions int `json:"more_locked_conditions,omitempty"`
|
||||||
UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"`
|
UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"`
|
||||||
|
LinkedTask *LinkedTask `json:"linked_task,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UnlockConditionDisplay struct {
|
type UnlockConditionDisplay struct {
|
||||||
@@ -2824,6 +2834,12 @@ func (a *App) initAuthDB() error {
|
|||||||
// Не возвращаем ошибку, чтобы приложение могло запуститься
|
// Не возвращаем ошибку, чтобы приложение могло запуститься
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply migration 021: Add wishlist_id to tasks
|
||||||
|
if err := a.applyMigration021(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to apply migration 021: %v", err)
|
||||||
|
// Не возвращаем ошибку, чтобы приложение могло запуститься
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up expired refresh tokens (only those with expiration date set)
|
// Clean up expired refresh tokens (only those with expiration date set)
|
||||||
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()")
|
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()")
|
||||||
|
|
||||||
@@ -3047,6 +3063,52 @@ func (a *App) applyMigration020() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyMigration021 применяет миграцию 021_add_wishlist_id_to_tasks.sql
|
||||||
|
func (a *App) applyMigration021() error {
|
||||||
|
log.Printf("Applying migration 021: Add wishlist_id to tasks")
|
||||||
|
|
||||||
|
// Проверяем, существует ли уже поле wishlist_id
|
||||||
|
var exists bool
|
||||||
|
err := a.DB.QueryRow(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'tasks'
|
||||||
|
AND column_name = 'wishlist_id'
|
||||||
|
)
|
||||||
|
`).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if wishlist_id exists: %w", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
log.Printf("Migration 021 already applied (wishlist_id column exists), skipping")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем SQL файл миграции
|
||||||
|
migrationPath := "/migrations/021_add_wishlist_id_to_tasks.sql"
|
||||||
|
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
|
||||||
|
// Пробуем альтернативный путь (для локальной разработки)
|
||||||
|
migrationPath = "play-life-backend/migrations/021_add_wishlist_id_to_tasks.sql"
|
||||||
|
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
|
||||||
|
migrationPath = "migrations/021_add_wishlist_id_to_tasks.sql"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrationSQL, err := os.ReadFile(migrationPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выполняем миграцию
|
||||||
|
if _, err := a.DB.Exec(string(migrationSQL)); err != nil {
|
||||||
|
return fmt.Errorf("failed to execute migration 021: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Migration 021 applied successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) initPlayLifeDB() error {
|
func (a *App) initPlayLifeDB() error {
|
||||||
// Создаем таблицу projects
|
// Создаем таблицу projects
|
||||||
createProjectsTable := `
|
createProjectsTable := `
|
||||||
@@ -6671,6 +6733,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
t.repetition_period::text,
|
t.repetition_period::text,
|
||||||
t.repetition_date,
|
t.repetition_date,
|
||||||
t.progression_base,
|
t.progression_base,
|
||||||
|
t.wishlist_id,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM tasks st
|
FROM tasks st
|
||||||
@@ -6714,6 +6777,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var repetitionPeriod sql.NullString
|
var repetitionPeriod sql.NullString
|
||||||
var repetitionDate sql.NullString
|
var repetitionDate sql.NullString
|
||||||
var progressionBase sql.NullFloat64
|
var progressionBase sql.NullFloat64
|
||||||
|
var wishlistID sql.NullInt64
|
||||||
var projectNames pq.StringArray
|
var projectNames pq.StringArray
|
||||||
var subtaskProjectNames pq.StringArray
|
var subtaskProjectNames pq.StringArray
|
||||||
|
|
||||||
@@ -6726,6 +6790,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
&repetitionPeriod,
|
&repetitionPeriod,
|
||||||
&repetitionDate,
|
&repetitionDate,
|
||||||
&progressionBase,
|
&progressionBase,
|
||||||
|
&wishlistID,
|
||||||
&task.SubtasksCount,
|
&task.SubtasksCount,
|
||||||
&projectNames,
|
&projectNames,
|
||||||
&subtaskProjectNames,
|
&subtaskProjectNames,
|
||||||
@@ -6753,6 +6818,10 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
task.HasProgression = false
|
task.HasProgression = false
|
||||||
}
|
}
|
||||||
|
if wishlistID.Valid {
|
||||||
|
wishlistIDInt := int(wishlistID.Int64)
|
||||||
|
task.WishlistID = &wishlistIDInt
|
||||||
|
}
|
||||||
|
|
||||||
// Объединяем проекты из основной задачи и подзадач
|
// Объединяем проекты из основной задачи и подзадач
|
||||||
allProjects := make(map[string]bool)
|
allProjects := make(map[string]bool)
|
||||||
@@ -6809,6 +6878,7 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var nextShowAt sql.NullString
|
var nextShowAt sql.NullString
|
||||||
var repetitionPeriod sql.NullString
|
var repetitionPeriod sql.NullString
|
||||||
var repetitionDate sql.NullString
|
var repetitionDate sql.NullString
|
||||||
|
var wishlistID sql.NullInt64
|
||||||
|
|
||||||
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
|
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
|
||||||
var repetitionPeriodStr string
|
var repetitionPeriodStr string
|
||||||
@@ -6816,11 +6886,12 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
err = a.DB.QueryRow(`
|
err = a.DB.QueryRow(`
|
||||||
SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base,
|
SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base,
|
||||||
CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period,
|
CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period,
|
||||||
COALESCE(repetition_date, '') as repetition_date
|
COALESCE(repetition_date, '') as repetition_date,
|
||||||
|
wishlist_id
|
||||||
FROM tasks
|
FROM tasks
|
||||||
WHERE id = $1 AND user_id = $2 AND deleted = FALSE
|
WHERE id = $1 AND user_id = $2 AND deleted = FALSE
|
||||||
`, taskID, userID).Scan(
|
`, taskID, userID).Scan(
|
||||||
&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr,
|
&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID,
|
||||||
)
|
)
|
||||||
|
|
||||||
log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr)
|
log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr)
|
||||||
@@ -6869,6 +6940,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
task.RepetitionDate = &repetitionDate.String
|
task.RepetitionDate = &repetitionDate.String
|
||||||
log.Printf("Task %d has repetition_date: %s", task.ID, repetitionDate.String)
|
log.Printf("Task %d has repetition_date: %s", task.ID, repetitionDate.String)
|
||||||
}
|
}
|
||||||
|
if wishlistID.Valid {
|
||||||
|
wishlistIDInt := int(wishlistID.Int64)
|
||||||
|
task.WishlistID = &wishlistIDInt
|
||||||
|
}
|
||||||
|
|
||||||
// Получаем награды основной задачи
|
// Получаем награды основной задачи
|
||||||
rewards := make([]Reward, 0)
|
rewards := make([]Reward, 0)
|
||||||
@@ -7045,6 +7120,77 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Валидация wishlist_id: если указан, проверяем что желание существует и принадлежит пользователю
|
||||||
|
var wishlistName string
|
||||||
|
if req.WishlistID != nil {
|
||||||
|
var wishlistOwnerID int
|
||||||
|
err := a.DB.QueryRow(`
|
||||||
|
SELECT user_id, name FROM wishlist_items
|
||||||
|
WHERE id = $1 AND deleted = FALSE
|
||||||
|
`, *req.WishlistID).Scan(&wishlistOwnerID, &wishlistName)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
sendErrorWithCORS(w, "Wishlist item not found", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error checking wishlist item: %v", err)
|
||||||
|
sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist item: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if wishlistOwnerID != userID {
|
||||||
|
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что нет другой задачи с таким wishlist_id
|
||||||
|
var existingTaskID int
|
||||||
|
err = a.DB.QueryRow(`
|
||||||
|
SELECT id FROM tasks
|
||||||
|
WHERE wishlist_id = $1 AND deleted = FALSE
|
||||||
|
`, *req.WishlistID).Scan(&existingTaskID)
|
||||||
|
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error checking existing task for wishlist: %v", err)
|
||||||
|
sendErrorWithCORS(w, fmt.Sprintf("Error checking existing task: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendErrorWithCORS(w, "Task already exists for this wishlist item", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если название задачи не указано или пустое, используем название желания
|
||||||
|
if strings.TrimSpace(req.Name) == "" {
|
||||||
|
req.Name = wishlistName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если сообщение награды не указано или пустое, устанавливаем "Выполнить желание: {TITLE}"
|
||||||
|
if req.RewardMessage == nil || strings.TrimSpace(*req.RewardMessage) == "" {
|
||||||
|
rewardMsg := fmt.Sprintf("Выполнить желание: %s", wishlistName)
|
||||||
|
req.RewardMessage = &rewardMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Задачи, привязанные к желанию, не могут быть периодическими
|
||||||
|
if (req.RepetitionPeriod != nil && strings.TrimSpace(*req.RepetitionPeriod) != "") ||
|
||||||
|
(req.RepetitionDate != nil && strings.TrimSpace(*req.RepetitionDate) != "") {
|
||||||
|
// Проверяем, что это не бесконечная задача (оба поля = 0)
|
||||||
|
isPeriodZero := req.RepetitionPeriod != nil && (strings.TrimSpace(*req.RepetitionPeriod) == "0 day" || strings.HasPrefix(strings.TrimSpace(*req.RepetitionPeriod), "0 "))
|
||||||
|
isDateZero := req.RepetitionDate != nil && (strings.TrimSpace(*req.RepetitionDate) == "0 week" || strings.HasPrefix(strings.TrimSpace(*req.RepetitionDate), "0 "))
|
||||||
|
if !isPeriodZero || !isDateZero {
|
||||||
|
sendErrorWithCORS(w, "Tasks linked to wishlist items cannot be periodic", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Задачи, привязанные к желанию, не могут иметь прогрессию
|
||||||
|
if req.ProgressionBase != nil {
|
||||||
|
sendErrorWithCORS(w, "Tasks linked to wishlist items cannot have progression", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Начинаем транзакцию
|
// Начинаем транзакцию
|
||||||
tx, err := a.DB.Begin()
|
tx, err := a.DB.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -7085,6 +7231,16 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
repetitionPeriodValue = nil
|
repetitionPeriodValue = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подготовка wishlist_id для INSERT
|
||||||
|
var wishlistIDValue interface{}
|
||||||
|
if req.WishlistID != nil {
|
||||||
|
wishlistIDValue = *req.WishlistID
|
||||||
|
log.Printf("Creating task with wishlist_id: %d", *req.WishlistID)
|
||||||
|
} else {
|
||||||
|
wishlistIDValue = nil
|
||||||
|
log.Printf("Creating task without wishlist_id")
|
||||||
|
}
|
||||||
|
|
||||||
// Используем условный SQL для обработки NULL значений
|
// Используем условный SQL для обработки NULL значений
|
||||||
var insertSQL string
|
var insertSQL string
|
||||||
var insertArgs []interface{}
|
var insertArgs []interface{}
|
||||||
@@ -7092,36 +7248,36 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Для repetition_period выставляем сегодняшнюю дату
|
// Для repetition_period выставляем сегодняшнюю дату
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
insertSQL = `
|
insertSQL = `
|
||||||
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted)
|
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id)
|
||||||
VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, $6, 0, FALSE)
|
VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, $6, 0, FALSE, $7)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`
|
`
|
||||||
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue, now}
|
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue, now, wishlistIDValue}
|
||||||
} else if repetitionDate.Valid {
|
} else if repetitionDate.Valid {
|
||||||
// Вычисляем next_show_at для задачи с repetition_date
|
// Вычисляем next_show_at для задачи с repetition_date
|
||||||
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now())
|
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now())
|
||||||
if nextShowAt != nil {
|
if nextShowAt != nil {
|
||||||
insertSQL = `
|
insertSQL = `
|
||||||
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted)
|
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id)
|
||||||
VALUES ($1, $2, $3, $4, NULL, $5, $6, 0, FALSE)
|
VALUES ($1, $2, $3, $4, NULL, $5, $6, 0, FALSE, $7)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`
|
`
|
||||||
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt}
|
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, wishlistIDValue}
|
||||||
} else {
|
} else {
|
||||||
insertSQL = `
|
insertSQL = `
|
||||||
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted)
|
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, wishlist_id)
|
||||||
VALUES ($1, $2, $3, $4, NULL, $5, 0, FALSE)
|
VALUES ($1, $2, $3, $4, NULL, $5, 0, FALSE, $6)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`
|
`
|
||||||
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String}
|
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, wishlistIDValue}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
insertSQL = `
|
insertSQL = `
|
||||||
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted)
|
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, wishlist_id)
|
||||||
VALUES ($1, $2, $3, $4, NULL, NULL, 0, FALSE)
|
VALUES ($1, $2, $3, $4, NULL, NULL, 0, FALSE, $5)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`
|
`
|
||||||
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase}
|
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, wishlistIDValue}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.QueryRow(insertSQL, insertArgs...).Scan(&taskID)
|
err = tx.QueryRow(insertSQL, insertArgs...).Scan(&taskID)
|
||||||
@@ -7313,6 +7469,53 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обработка wishlist_id: можно только отвязать (установить в NULL), нельзя привязать
|
||||||
|
// Если req.WishlistID == nil, значит пользователь хочет отвязать (или не трогать)
|
||||||
|
// Если req.WishlistID != nil, игнорируем (нельзя привязать при редактировании)
|
||||||
|
// Получаем текущий wishlist_id задачи
|
||||||
|
var currentWishlistID sql.NullInt64
|
||||||
|
err = a.DB.QueryRow("SELECT wishlist_id FROM tasks WHERE id = $1", taskID).Scan(¤tWishlistID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting current wishlist_id: %v", err)
|
||||||
|
sendErrorWithCORS(w, fmt.Sprintf("Error getting task: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем новое значение wishlist_id
|
||||||
|
// Если задача была привязана и req.WishlistID == nil, значит отвязываем
|
||||||
|
// Если req.WishlistID != nil, игнорируем (нельзя привязать)
|
||||||
|
var newWishlistID interface{}
|
||||||
|
if currentWishlistID.Valid && req.WishlistID == nil {
|
||||||
|
// Отвязываем от желания
|
||||||
|
newWishlistID = nil
|
||||||
|
} else if currentWishlistID.Valid {
|
||||||
|
// Оставляем текущее значение (нельзя привязать)
|
||||||
|
newWishlistID = currentWishlistID.Int64
|
||||||
|
} else {
|
||||||
|
// Задача не была привязана, оставляем NULL
|
||||||
|
newWishlistID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если задача привязана к желанию, не позволяем устанавливать повторения и прогрессию
|
||||||
|
if currentWishlistID.Valid {
|
||||||
|
if (req.RepetitionPeriod != nil && strings.TrimSpace(*req.RepetitionPeriod) != "") ||
|
||||||
|
(req.RepetitionDate != nil && strings.TrimSpace(*req.RepetitionDate) != "") {
|
||||||
|
// Проверяем, что это не бесконечная задача (оба поля = 0)
|
||||||
|
isPeriodZero := req.RepetitionPeriod != nil && (strings.TrimSpace(*req.RepetitionPeriod) == "0 day" || strings.HasPrefix(strings.TrimSpace(*req.RepetitionPeriod), "0 "))
|
||||||
|
isDateZero := req.RepetitionDate != nil && (strings.TrimSpace(*req.RepetitionDate) == "0 week" || strings.HasPrefix(strings.TrimSpace(*req.RepetitionDate), "0 "))
|
||||||
|
if !isPeriodZero || !isDateZero {
|
||||||
|
sendErrorWithCORS(w, "Tasks linked to wishlist items cannot be periodic", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Задачи, привязанные к желанию, не могут иметь прогрессию
|
||||||
|
if req.ProgressionBase != nil {
|
||||||
|
sendErrorWithCORS(w, "Tasks linked to wishlist items cannot have progression", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Начинаем транзакцию
|
// Начинаем транзакцию
|
||||||
tx, err := a.DB.Begin()
|
tx, err := a.DB.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -7352,35 +7555,35 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
updateSQL = `
|
updateSQL = `
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = $4::INTERVAL, repetition_date = NULL, next_show_at = $5
|
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = $4::INTERVAL, repetition_date = NULL, next_show_at = $5, wishlist_id = $6
|
||||||
WHERE id = $6
|
WHERE id = $7
|
||||||
`
|
`
|
||||||
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, now, taskID}
|
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, now, newWishlistID, taskID}
|
||||||
} else if repetitionDate.Valid {
|
} else if repetitionDate.Valid {
|
||||||
// Вычисляем next_show_at для задачи с repetition_date
|
// Вычисляем next_show_at для задачи с repetition_date
|
||||||
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now())
|
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now())
|
||||||
if nextShowAt != nil {
|
if nextShowAt != nil {
|
||||||
updateSQL = `
|
updateSQL = `
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, next_show_at = $5
|
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, next_show_at = $5, wishlist_id = $6
|
||||||
WHERE id = $6
|
WHERE id = $7
|
||||||
`
|
`
|
||||||
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, taskID}
|
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, newWishlistID, taskID}
|
||||||
} else {
|
} else {
|
||||||
updateSQL = `
|
updateSQL = `
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4
|
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, wishlist_id = $5
|
||||||
WHERE id = $5
|
WHERE id = $6
|
||||||
`
|
`
|
||||||
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, taskID}
|
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, newWishlistID, taskID}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updateSQL = `
|
updateSQL = `
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = NULL, next_show_at = NULL
|
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = NULL, next_show_at = NULL, wishlist_id = $4
|
||||||
WHERE id = $4
|
WHERE id = $5
|
||||||
`
|
`
|
||||||
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, taskID}
|
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, newWishlistID, taskID}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(updateSQL, updateArgs...)
|
_, err = tx.Exec(updateSQL, updateArgs...)
|
||||||
@@ -7701,12 +7904,13 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var repetitionPeriod sql.NullString
|
var repetitionPeriod sql.NullString
|
||||||
var repetitionDate sql.NullString
|
var repetitionDate sql.NullString
|
||||||
var ownerID int
|
var ownerID int
|
||||||
|
var wishlistID sql.NullInt64
|
||||||
|
|
||||||
err = a.DB.QueryRow(`
|
err = a.DB.QueryRow(`
|
||||||
SELECT id, name, reward_message, progression_base, repetition_period::text, repetition_date, user_id
|
SELECT id, name, reward_message, progression_base, repetition_period::text, repetition_date, user_id, wishlist_id
|
||||||
FROM tasks
|
FROM tasks
|
||||||
WHERE id = $1 AND deleted = FALSE
|
WHERE id = $1 AND deleted = FALSE
|
||||||
`, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &repetitionDate, &ownerID)
|
`, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &repetitionDate, &ownerID, &wishlistID)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
|
sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
|
||||||
@@ -7723,6 +7927,20 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем, что желание разблокировано (если задача связана с желанием)
|
||||||
|
if wishlistID.Valid {
|
||||||
|
unlocked, err := a.checkWishlistUnlock(int(wishlistID.Int64), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error checking wishlist unlock status: %v", err)
|
||||||
|
sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist unlock status: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !unlocked {
|
||||||
|
sendErrorWithCORS(w, "Cannot complete task: wishlist item is not unlocked", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Валидация: если progression_base != null, то value обязателен
|
// Валидация: если progression_base != null, то value обязателен
|
||||||
if progressionBase.Valid && req.Value == nil {
|
if progressionBase.Valid && req.Value == nil {
|
||||||
sendErrorWithCORS(w, "Value is required when progression_base is set", http.StatusBadRequest)
|
sendErrorWithCORS(w, "Value is required when progression_base is set", http.StatusBadRequest)
|
||||||
@@ -8052,6 +8270,23 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если задача связана с желанием, завершаем желание
|
||||||
|
// Используем wishlistID из начала функции (уже объявлена)
|
||||||
|
if wishlistID.Valid {
|
||||||
|
// Завершаем желание
|
||||||
|
_, completeErr := a.DB.Exec(`
|
||||||
|
UPDATE wishlist_items
|
||||||
|
SET completed = TRUE
|
||||||
|
WHERE id = $1 AND user_id = $2 AND completed = FALSE
|
||||||
|
`, wishlistID.Int64, userID)
|
||||||
|
if completeErr != nil {
|
||||||
|
log.Printf("Error completing wishlist item %d: %v", wishlistID.Int64, completeErr)
|
||||||
|
// Не возвращаем ошибку, задача уже выполнена
|
||||||
|
} else {
|
||||||
|
log.Printf("Wishlist item %d completed automatically after task %d completion", wishlistID.Int64, taskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -8097,12 +8332,13 @@ func (a *App) completeAndDeleteTaskHandler(w http.ResponseWriter, r *http.Reques
|
|||||||
var repetitionPeriod sql.NullString
|
var repetitionPeriod sql.NullString
|
||||||
var repetitionDate sql.NullString
|
var repetitionDate sql.NullString
|
||||||
var ownerID int
|
var ownerID int
|
||||||
|
var wishlistID sql.NullInt64
|
||||||
|
|
||||||
err = a.DB.QueryRow(`
|
err = a.DB.QueryRow(`
|
||||||
SELECT id, name, reward_message, progression_base, repetition_period::text, repetition_date, user_id
|
SELECT id, name, reward_message, progression_base, repetition_period::text, repetition_date, user_id, wishlist_id
|
||||||
FROM tasks
|
FROM tasks
|
||||||
WHERE id = $1 AND deleted = FALSE
|
WHERE id = $1 AND deleted = FALSE
|
||||||
`, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &repetitionDate, &ownerID)
|
`, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &repetitionDate, &ownerID, &wishlistID)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
|
sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
|
||||||
@@ -8119,6 +8355,20 @@ func (a *App) completeAndDeleteTaskHandler(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем, что желание разблокировано (если задача связана с желанием)
|
||||||
|
if wishlistID.Valid {
|
||||||
|
unlocked, err := a.checkWishlistUnlock(int(wishlistID.Int64), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error checking wishlist unlock status: %v", err)
|
||||||
|
sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist unlock status: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !unlocked {
|
||||||
|
sendErrorWithCORS(w, "Cannot complete task: wishlist item is not unlocked", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Валидация: если progression_base != null, то value обязателен
|
// Валидация: если progression_base != null, то value обязателен
|
||||||
if progressionBase.Valid && req.Value == nil {
|
if progressionBase.Valid && req.Value == nil {
|
||||||
sendErrorWithCORS(w, "Value is required when progression_base is set", http.StatusBadRequest)
|
sendErrorWithCORS(w, "Value is required when progression_base is set", http.StatusBadRequest)
|
||||||
@@ -8352,6 +8602,23 @@ func (a *App) completeAndDeleteTaskHandler(w http.ResponseWriter, r *http.Reques
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если задача связана с желанием, завершаем желание (до удаления задачи)
|
||||||
|
// Используем wishlistID из начала функции (уже объявлена)
|
||||||
|
if wishlistID.Valid {
|
||||||
|
// Завершаем желание
|
||||||
|
_, completeErr := a.DB.Exec(`
|
||||||
|
UPDATE wishlist_items
|
||||||
|
SET completed = TRUE
|
||||||
|
WHERE id = $1 AND user_id = $2 AND completed = FALSE
|
||||||
|
`, wishlistID.Int64, userID)
|
||||||
|
if completeErr != nil {
|
||||||
|
log.Printf("Error completing wishlist item %d: %v", wishlistID.Int64, completeErr)
|
||||||
|
// Не возвращаем ошибку, задача уже выполнена
|
||||||
|
} else {
|
||||||
|
log.Printf("Wishlist item %d completed automatically after task %d completion", wishlistID.Int64, taskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Помечаем задачу как удаленную
|
// Помечаем задачу как удаленную
|
||||||
_, err = a.DB.Exec("UPDATE tasks SET deleted = TRUE WHERE id = $1 AND user_id = $2", taskID, userID)
|
_, err = a.DB.Exec("UPDATE tasks SET deleted = TRUE WHERE id = $1 AND user_id = $2", taskID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -8870,6 +9137,33 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Загружаем связанную задачу, если есть
|
||||||
|
var linkedTaskID, linkedTaskCompleted sql.NullInt64
|
||||||
|
var linkedTaskName sql.NullString
|
||||||
|
var linkedTaskNextShowAt sql.NullTime
|
||||||
|
linkedTaskErr := a.DB.QueryRow(`
|
||||||
|
SELECT t.id, t.name, t.completed, t.next_show_at
|
||||||
|
FROM tasks t
|
||||||
|
WHERE t.wishlist_id = $1 AND t.deleted = FALSE
|
||||||
|
LIMIT 1
|
||||||
|
`, item.ID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt)
|
||||||
|
|
||||||
|
if linkedTaskErr == nil && linkedTaskID.Valid {
|
||||||
|
linkedTask := &LinkedTask{
|
||||||
|
ID: int(linkedTaskID.Int64),
|
||||||
|
Name: linkedTaskName.String,
|
||||||
|
Completed: int(linkedTaskCompleted.Int64),
|
||||||
|
}
|
||||||
|
if linkedTaskNextShowAt.Valid {
|
||||||
|
nextShowAtStr := linkedTaskNextShowAt.Time.Format(time.RFC3339)
|
||||||
|
linkedTask.NextShowAt = &nextShowAtStr
|
||||||
|
}
|
||||||
|
item.LinkedTask = linkedTask
|
||||||
|
} else if linkedTaskErr != sql.ErrNoRows {
|
||||||
|
log.Printf("Error loading linked task for wishlist %d: %v", item.ID, linkedTaskErr)
|
||||||
|
// Не возвращаем ошибку, просто не устанавливаем linked_task
|
||||||
|
}
|
||||||
|
|
||||||
items = append(items, *item)
|
items = append(items, *item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9026,15 +9320,24 @@ func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Получаем количество завершённых отдельным запросом (т.к. основной запрос может их не включать)
|
||||||
|
var completedCount int
|
||||||
|
err = a.DB.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM wishlist_items
|
||||||
|
WHERE user_id = $1 AND deleted = FALSE AND completed = TRUE
|
||||||
|
`, userID).Scan(&completedCount)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error counting completed wishlist items: %v", err)
|
||||||
|
completedCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
// Группируем и сортируем
|
// Группируем и сортируем
|
||||||
unlocked := make([]WishlistItem, 0)
|
unlocked := make([]WishlistItem, 0)
|
||||||
locked := make([]WishlistItem, 0)
|
locked := make([]WishlistItem, 0)
|
||||||
completed := make([]WishlistItem, 0)
|
completed := make([]WishlistItem, 0)
|
||||||
completedCount := 0
|
|
||||||
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if item.Completed {
|
if item.Completed {
|
||||||
completedCount++
|
|
||||||
if includeCompleted {
|
if includeCompleted {
|
||||||
completed = append(completed, item)
|
completed = append(completed, item)
|
||||||
}
|
}
|
||||||
@@ -9224,6 +9527,33 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Загружаем связанную задачу, если есть
|
||||||
|
var linkedTaskID, linkedTaskCompleted sql.NullInt64
|
||||||
|
var linkedTaskName sql.NullString
|
||||||
|
var linkedTaskNextShowAt sql.NullTime
|
||||||
|
err = a.DB.QueryRow(`
|
||||||
|
SELECT t.id, t.name, t.completed, t.next_show_at
|
||||||
|
FROM tasks t
|
||||||
|
WHERE t.wishlist_id = $1 AND t.deleted = FALSE
|
||||||
|
LIMIT 1
|
||||||
|
`, itemID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt)
|
||||||
|
|
||||||
|
if err == nil && linkedTaskID.Valid {
|
||||||
|
linkedTask := &LinkedTask{
|
||||||
|
ID: int(linkedTaskID.Int64),
|
||||||
|
Name: linkedTaskName.String,
|
||||||
|
Completed: int(linkedTaskCompleted.Int64),
|
||||||
|
}
|
||||||
|
if linkedTaskNextShowAt.Valid {
|
||||||
|
nextShowAtStr := linkedTaskNextShowAt.Time.Format(time.RFC3339)
|
||||||
|
linkedTask.NextShowAt = &nextShowAtStr
|
||||||
|
}
|
||||||
|
item.LinkedTask = linkedTask
|
||||||
|
} else if err != sql.ErrNoRows {
|
||||||
|
log.Printf("Error loading linked task for wishlist %d: %v", itemID, err)
|
||||||
|
// Не возвращаем ошибку, просто не устанавливаем linked_task
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(item)
|
json.NewEncoder(w).Encode(item)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Migration: Add wishlist_id to tasks table for linking tasks to wishlist items
|
||||||
|
-- This allows creating tasks directly from wishlist items and tracking the relationship
|
||||||
|
|
||||||
|
-- Добавляем поле wishlist_id в таблицу tasks
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS wishlist_id INTEGER REFERENCES wishlist_items(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Создаём индекс для быстрого поиска задач по wishlist_id
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_wishlist_id ON tasks(wishlist_id);
|
||||||
|
|
||||||
|
-- Уникальный индекс: только одна незавершённая задача на желание
|
||||||
|
-- Это предотвращает создание нескольких задач для одного желания
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_wishlist_id_unique
|
||||||
|
ON tasks(wishlist_id) WHERE wishlist_id IS NOT NULL AND deleted = FALSE;
|
||||||
|
|
||||||
|
-- Добавляем комментарий для документации
|
||||||
|
COMMENT ON COLUMN tasks.wishlist_id IS 'Link to wishlist item that this task fulfills. NULL if task is not linked to any wishlist item.';
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "3.9.4",
|
"version": "3.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -626,9 +626,11 @@ function AppContent() {
|
|||||||
updateUrl(tab, {}, activeTab)
|
updateUrl(tab, {}, activeTab)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Для task-form и wishlist-form явно удаляем параметры, если они undefined
|
// Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров
|
||||||
if ((tab === 'task-form' && params.taskId === undefined) ||
|
// task-form может иметь taskId (редактирование) или wishlistId (создание из желания)
|
||||||
(tab === 'wishlist-form' && params.wishlistId === undefined)) {
|
const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined
|
||||||
|
const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined
|
||||||
|
if (isTaskFormWithNoParams || isWishlistFormWithNoParams) {
|
||||||
setTabParams({})
|
setTabParams({})
|
||||||
if (isNewTabMain) {
|
if (isNewTabMain) {
|
||||||
clearUrl()
|
clearUrl()
|
||||||
@@ -865,6 +867,7 @@ function AppContent() {
|
|||||||
key={tabParams.taskId || 'new'}
|
key={tabParams.taskId || 'new'}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
taskId={tabParams.taskId}
|
taskId={tabParams.taskId}
|
||||||
|
wishlistId={tabParams.wishlistId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -273,3 +273,47 @@
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-wishlist-link {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #bae6fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-wishlist-link-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-wishlist-link-info svg {
|
||||||
|
color: #6366f1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-wishlist-link-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-wishlist-link-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: underline;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-wishlist-link-button:hover {
|
||||||
|
background-color: rgba(99, 102, 241, 0.1);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ const formatTelegramMessage = (task, rewards, subtasks, selectedSubtasks, progre
|
|||||||
return finalMessage
|
return finalMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted, onNavigate }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [taskDetail, setTaskDetail] = useState(null)
|
const [taskDetail, setTaskDetail] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -382,6 +382,7 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
|||||||
const [progressionValue, setProgressionValue] = useState('')
|
const [progressionValue, setProgressionValue] = useState('')
|
||||||
const [isCompleting, setIsCompleting] = useState(false)
|
const [isCompleting, setIsCompleting] = useState(false)
|
||||||
const [toastMessage, setToastMessage] = useState(null)
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
|
const [wishlistInfo, setWishlistInfo] = useState(null)
|
||||||
|
|
||||||
const fetchTaskDetail = useCallback(async () => {
|
const fetchTaskDetail = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -393,6 +394,25 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
|||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setTaskDetail(data)
|
setTaskDetail(data)
|
||||||
|
|
||||||
|
// Загружаем информацию о связанном желании, если есть
|
||||||
|
if (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,
|
||||||
|
unlocked: wishlistData.unlocked || false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading wishlist info:', err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setWishlistInfo(null)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
console.error('Error fetching task detail:', err)
|
console.error('Error fetching task detail:', err)
|
||||||
@@ -429,6 +449,12 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
|||||||
const handleComplete = async (shouldDelete = false) => {
|
const handleComplete = async (shouldDelete = false) => {
|
||||||
if (!taskDetail) return
|
if (!taskDetail) return
|
||||||
|
|
||||||
|
// Проверяем, что желание разблокировано (если есть связанное желание)
|
||||||
|
if (wishlistInfo && !wishlistInfo.unlocked) {
|
||||||
|
setToastMessage({ text: 'Невозможно выполнить задачу: желание не разблокировано', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Если прогрессия не введена, используем 0 (валидация не требуется)
|
// Если прогрессия не введена, используем 0 (валидация не требуется)
|
||||||
|
|
||||||
setIsCompleting(true)
|
setIsCompleting(true)
|
||||||
@@ -492,8 +518,8 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
|||||||
|
|
||||||
const { task, rewards, subtasks } = taskDetail || {}
|
const { task, rewards, subtasks } = taskDetail || {}
|
||||||
const hasProgression = task?.progression_base != null
|
const hasProgression = task?.progression_base != null
|
||||||
// Кнопка всегда активна (если прогрессия не введена, используем 0)
|
// Кнопка активна только если желание разблокировано (или задачи нет связанного желания)
|
||||||
const canComplete = true
|
const canComplete = !wishlistInfo || wishlistInfo.unlocked
|
||||||
|
|
||||||
// Определяем, является ли задача одноразовой
|
// Определяем, является ли задача одноразовой
|
||||||
// Одноразовая задача: когда оба поля null/undefined (из бэкенда видно, что в этом случае задача помечается как deleted)
|
// Одноразовая задача: когда оба поля null/undefined (из бэкенда видно, что в этом случае задача помечается как deleted)
|
||||||
@@ -556,6 +582,33 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
|||||||
|
|
||||||
{!loading && !error && taskDetail && (
|
{!loading && !error && taskDetail && (
|
||||||
<>
|
<>
|
||||||
|
{/* Информация о связанном желании */}
|
||||||
|
{task.wishlist_id && wishlistInfo && (
|
||||||
|
<div className="task-wishlist-link">
|
||||||
|
<div className="task-wishlist-link-info">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 12 20 22 4 22 4 12"></polyline>
|
||||||
|
<rect x="2" y="7" width="20" height="5"></rect>
|
||||||
|
<line x1="12" y1="22" x2="12" y2="7"></line>
|
||||||
|
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
|
||||||
|
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
|
||||||
|
</svg>
|
||||||
|
<span className="task-wishlist-link-label">Связано с желанием:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (onClose) onClose()
|
||||||
|
if (onNavigate && wishlistInfo) {
|
||||||
|
onNavigate('wishlist-detail', { wishlistId: wishlistInfo.id })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="task-wishlist-link-button"
|
||||||
|
>
|
||||||
|
{wishlistInfo.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Поле ввода прогрессии */}
|
{/* Поле ввода прогрессии */}
|
||||||
{hasProgression && (
|
{hasProgression && (
|
||||||
<div className="progression-section">
|
<div className="progression-section">
|
||||||
@@ -619,13 +672,20 @@ function TaskDetail({ taskId, onClose, onRefresh, onTaskCompleted }) {
|
|||||||
onClick={() => handleComplete(false)}
|
onClick={() => handleComplete(false)}
|
||||||
disabled={isCompleting || !canComplete}
|
disabled={isCompleting || !canComplete}
|
||||||
className="complete-button"
|
className="complete-button"
|
||||||
|
title={!canComplete && wishlistInfo ? 'Желание не разблокировано' : ''}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginRight: '0.5rem' }}>
|
{!canComplete && wishlistInfo ? (
|
||||||
<path d="M13.5 4L6 11.5L2.5 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '0.5rem' }}>
|
||||||
</svg>
|
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginRight: '0.5rem' }}>
|
||||||
|
<path d="M13.5 4L6 11.5L2.5 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
{isCompleting ? 'Выполнение...' : 'Выполнить'}
|
{isCompleting ? 'Выполнение...' : 'Выполнить'}
|
||||||
</button>
|
</button>
|
||||||
{!isOneTime && (
|
{!isOneTime && canComplete && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleComplete(true)}
|
onClick={() => handleComplete(true)}
|
||||||
disabled={isCompleting || !canComplete}
|
disabled={isCompleting || !canComplete}
|
||||||
|
|||||||
@@ -370,3 +370,46 @@
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wishlist-link-info {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #bae6fd;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-link-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #374151;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-link-text strong {
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-unlink-x {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-unlink-x:hover {
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import './TaskForm.css'
|
|||||||
const API_URL = '/api/tasks'
|
const API_URL = '/api/tasks'
|
||||||
const PROJECTS_API_URL = '/projects'
|
const PROJECTS_API_URL = '/projects'
|
||||||
|
|
||||||
function TaskForm({ onNavigate, taskId }) {
|
function TaskForm({ onNavigate, taskId, wishlistId }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [progressionBase, setProgressionBase] = useState('')
|
const [progressionBase, setProgressionBase] = useState('')
|
||||||
@@ -22,6 +22,8 @@ function TaskForm({ onNavigate, taskId }) {
|
|||||||
const [toastMessage, setToastMessage] = useState(null)
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
const [loadingTask, setLoadingTask] = useState(false)
|
const [loadingTask, setLoadingTask] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании
|
||||||
|
const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи
|
||||||
const debounceTimer = useRef(null)
|
const debounceTimer = useRef(null)
|
||||||
|
|
||||||
// Загрузка проектов для автокомплита
|
// Загрузка проектов для автокомплита
|
||||||
@@ -65,8 +67,37 @@ function TaskForm({ onNavigate, taskId }) {
|
|||||||
} else {
|
} else {
|
||||||
// Сбрасываем форму при создании новой задачи
|
// Сбрасываем форму при создании новой задачи
|
||||||
resetForm()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [taskId])
|
}, [taskId, wishlistId, authFetch])
|
||||||
|
|
||||||
const loadTask = async () => {
|
const loadTask = async () => {
|
||||||
setLoadingTask(true)
|
setLoadingTask(true)
|
||||||
@@ -263,6 +294,28 @@ function TaskForm({ onNavigate, taskId }) {
|
|||||||
use_progression: r.use_progression
|
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)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCurrentWishlistId(null)
|
||||||
|
setWishlistInfo(null)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -422,12 +475,31 @@ function TaskForm({ onNavigate, taskId }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем, что задача с привязанным желанием не может быть периодической
|
||||||
|
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 {
|
try {
|
||||||
// Преобразуем период повторения в строку INTERVAL для PostgreSQL или repetition_date
|
// Преобразуем период повторения в строку INTERVAL для PostgreSQL или repetition_date
|
||||||
let repetitionPeriod = null
|
let repetitionPeriod = null
|
||||||
let repetitionDate = null
|
let repetitionDate = null
|
||||||
|
|
||||||
if (repetitionPeriodValue && repetitionPeriodValue.trim() !== '') {
|
// Если задача привязана к желанию, не устанавливаем повторения
|
||||||
|
if (!isLinkedToWishlist && repetitionPeriodValue && repetitionPeriodValue.trim() !== '') {
|
||||||
const valueStr = repetitionPeriodValue.trim()
|
const valueStr = repetitionPeriodValue.trim()
|
||||||
const value = parseInt(valueStr, 10)
|
const value = parseInt(valueStr, 10)
|
||||||
|
|
||||||
@@ -482,9 +554,16 @@ function TaskForm({ onNavigate, taskId }) {
|
|||||||
const payload = {
|
const payload = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
reward_message: rewardMessage.trim() || null,
|
reward_message: rewardMessage.trim() || null,
|
||||||
progression_base: progressionBase ? parseFloat(progressionBase) : null,
|
// Если задача привязана к желанию, не отправляем progression_base
|
||||||
|
progression_base: isLinkedToWishlist ? null : (progressionBase ? parseFloat(progressionBase) : null),
|
||||||
repetition_period: repetitionPeriod,
|
repetition_period: repetitionPeriod,
|
||||||
repetition_date: repetitionDate,
|
repetition_date: repetitionDate,
|
||||||
|
// При создании: отправляем currentWishlistId если указан (уже число)
|
||||||
|
// При редактировании: отправляем null только если была привязка (currentWishlistId) и пользователь отвязал (!wishlistInfo)
|
||||||
|
// Если не было привязки или привязка осталась - не отправляем поле (undefined)
|
||||||
|
wishlist_id: taskId
|
||||||
|
? (currentWishlistId && !wishlistInfo ? null : undefined)
|
||||||
|
: (currentWishlistId || undefined),
|
||||||
rewards: rewards.map(r => ({
|
rewards: rewards.map(r => ({
|
||||||
position: r.position,
|
position: r.position,
|
||||||
project_name: r.project_name.trim(),
|
project_name: r.project_name.trim(),
|
||||||
@@ -543,6 +622,13 @@ function TaskForm({ onNavigate, taskId }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUnlinkWishlist = () => {
|
||||||
|
if (window.confirm('Отвязать задачу от желания?')) {
|
||||||
|
setCurrentWishlistId(null)
|
||||||
|
setWishlistInfo(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
resetForm()
|
resetForm()
|
||||||
onNavigate?.('tasks')
|
onNavigate?.('tasks')
|
||||||
@@ -608,6 +694,27 @@ function TaskForm({ onNavigate, taskId }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Информация о связанном желании */}
|
||||||
|
{wishlistInfo && (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="wishlist-link-info">
|
||||||
|
<span className="wishlist-link-text">
|
||||||
|
Связана с желанием: <strong>{wishlistInfo.name}</strong>
|
||||||
|
</span>
|
||||||
|
{taskId && currentWishlistId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUnlinkWishlist}
|
||||||
|
className="wishlist-unlink-x"
|
||||||
|
title="Отвязать от желания"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="progression_base">Прогрессия</label>
|
<label htmlFor="progression_base">Прогрессия</label>
|
||||||
<input
|
<input
|
||||||
@@ -615,18 +722,24 @@ function TaskForm({ onNavigate, taskId }) {
|
|||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={progressionBase}
|
value={progressionBase}
|
||||||
onChange={(e) => setProgressionBase(e.target.value)}
|
onChange={(e) => {
|
||||||
|
if (!wishlistInfo) {
|
||||||
|
setProgressionBase(e.target.value)
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder="Базовое значение"
|
placeholder="Базовое значение"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
|
disabled={wishlistInfo !== null}
|
||||||
/>
|
/>
|
||||||
<small style={{ color: '#666', fontSize: '0.9em' }}>
|
<small style={{ color: wishlistInfo ? '#e74c3c' : '#666', fontSize: '0.9em' }}>
|
||||||
Оставьте пустым, если прогрессия не используется
|
{wishlistInfo ? 'Задачи, привязанные к желанию, не могут иметь прогрессию' : 'Оставьте пустым, если прогрессия не используется'}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="repetition_period">Повторения</label>
|
<label htmlFor="repetition_period">Повторения</label>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
const isLinkedToWishlist = wishlistInfo !== null
|
||||||
const hasValidValue = repetitionPeriodValue && repetitionPeriodValue.trim() !== '' && parseInt(repetitionPeriodValue.trim(), 10) !== 0
|
const hasValidValue = repetitionPeriodValue && repetitionPeriodValue.trim() !== '' && parseInt(repetitionPeriodValue.trim(), 10) !== 0
|
||||||
const isEachMode = hasValidValue && repetitionMode === 'each'
|
const isEachMode = hasValidValue && repetitionMode === 'each'
|
||||||
const isYearType = isEachMode && repetitionPeriodType === 'year'
|
const isYearType = isEachMode && repetitionPeriodType === 'year'
|
||||||
@@ -634,7 +747,7 @@ function TaskForm({ onNavigate, taskId }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
{hasValidValue && (
|
{hasValidValue && !isLinkedToWishlist && (
|
||||||
<select
|
<select
|
||||||
value={repetitionMode}
|
value={repetitionMode}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -659,12 +772,17 @@ function TaskForm({ onNavigate, taskId }) {
|
|||||||
type={isYearType ? 'text' : 'number'}
|
type={isYearType ? 'text' : 'number'}
|
||||||
min="0"
|
min="0"
|
||||||
value={repetitionPeriodValue}
|
value={repetitionPeriodValue}
|
||||||
onChange={(e) => setRepetitionPeriodValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
if (!isLinkedToWishlist) {
|
||||||
|
setRepetitionPeriodValue(e.target.value)
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={isYearType ? 'ММ-ДД' : 'Число'}
|
placeholder={isYearType ? 'ММ-ДД' : 'Число'}
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ flex: '1' }}
|
style={{ flex: '1' }}
|
||||||
|
disabled={isLinkedToWishlist}
|
||||||
/>
|
/>
|
||||||
{hasValidValue && (
|
{hasValidValue && !isLinkedToWishlist && (
|
||||||
<select
|
<select
|
||||||
value={repetitionPeriodType}
|
value={repetitionPeriodType}
|
||||||
onChange={(e) => setRepetitionPeriodType(e.target.value)}
|
onChange={(e) => setRepetitionPeriodType(e.target.value)}
|
||||||
@@ -691,7 +809,9 @@ function TaskForm({ onNavigate, taskId }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<small style={{ color: '#666', fontSize: '0.9em' }}>
|
<small style={{ color: '#666', fontSize: '0.9em' }}>
|
||||||
{isEachMode ? (
|
{isLinkedToWishlist ? (
|
||||||
|
<span style={{ color: '#e74c3c' }}>Задачи, привязанные к желанию, не могут быть периодическими</span>
|
||||||
|
) : isEachMode ? (
|
||||||
repetitionPeriodType === 'week' ? 'Номер дня недели (1-7, где 1 = понедельник)' :
|
repetitionPeriodType === 'week' ? 'Номер дня недели (1-7, где 1 = понедельник)' :
|
||||||
repetitionPeriodType === 'month' ? 'Номер дня месяца (1-31)' :
|
repetitionPeriodType === 'month' ? 'Номер дня месяца (1-31)' :
|
||||||
'Дата в формате ММ-ДД (например, 02-01 для 1 февраля)'
|
'Дата в формате ММ-ДД (например, 02-01 для 1 февраля)'
|
||||||
|
|||||||
@@ -733,6 +733,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
|
|||||||
onClose={handleCloseDetail}
|
onClose={handleCloseDetail}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
onTaskCompleted={() => setToast({ message: 'Задача выполнена', type: 'success' })}
|
onTaskCompleted={() => setToast({ message: 'Задача выполнена', type: 'success' })}
|
||||||
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -474,11 +474,6 @@ function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
<button className="wishlist-modal-copy" onClick={handleCopy}>
|
<button className="wishlist-modal-copy" onClick={handleCopy}>
|
||||||
Копировать
|
Копировать
|
||||||
</button>
|
</button>
|
||||||
{!selectedItem.completed && selectedItem.unlocked && (
|
|
||||||
<button className="wishlist-modal-complete" onClick={handleComplete}>
|
|
||||||
Завершить
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="wishlist-modal-delete" onClick={handleDelete}>
|
<button className="wishlist-modal-delete" onClick={handleDelete}>
|
||||||
Удалить
|
Удалить
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -164,9 +164,10 @@
|
|||||||
|
|
||||||
.wishlist-detail-actions {
|
.wishlist-detail-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wishlist-detail-edit-button,
|
.wishlist-detail-edit-button,
|
||||||
@@ -194,6 +195,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wishlist-detail-complete-button {
|
.wishlist-detail-complete-button {
|
||||||
|
flex: 1;
|
||||||
background-color: #27ae60;
|
background-color: #27ae60;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@@ -208,6 +210,86 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-create-task-button {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #27ae60;
|
||||||
|
border: 2px solid #27ae60;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-create-task-button:hover {
|
||||||
|
background-color: rgba(39, 174, 96, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-linked-task {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-task-label-header {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-linked-task .task-item {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-linked-task .task-item-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-linked-task .task-name-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-linked-task .task-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-linked-task .task-unlink-button {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-detail-linked-task .task-unlink-button:hover {
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
.wishlist-detail-uncomplete-button {
|
.wishlist-detail-uncomplete-button {
|
||||||
background-color: #f39c12;
|
background-color: #f39c12;
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { useAuth } from './auth/AuthContext'
|
import { useAuth } from './auth/AuthContext'
|
||||||
|
import TaskDetail from './TaskDetail'
|
||||||
import LoadingError from './LoadingError'
|
import LoadingError from './LoadingError'
|
||||||
import Toast from './Toast'
|
import Toast from './Toast'
|
||||||
import './WishlistDetail.css'
|
import './WishlistDetail.css'
|
||||||
|
import './TaskList.css'
|
||||||
|
|
||||||
const API_URL = '/api/wishlist'
|
const API_URL = '/api/wishlist'
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
|
|||||||
const [isCompleting, setIsCompleting] = useState(false)
|
const [isCompleting, setIsCompleting] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [toastMessage, setToastMessage] = useState(null)
|
const [toastMessage, setToastMessage] = useState(null)
|
||||||
|
const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null)
|
||||||
|
|
||||||
const fetchWishlistDetail = useCallback(async () => {
|
const fetchWishlistDetail = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -134,6 +137,106 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCreateTask = () => {
|
||||||
|
if (!wishlistItem || !wishlistItem.unlocked || wishlistItem.completed) return
|
||||||
|
onNavigate?.('task-form', { wishlistId: wishlistId })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTaskCheckmarkClick = (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (wishlistItem?.linked_task) {
|
||||||
|
setSelectedTaskForDetail(wishlistItem.linked_task.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTaskItemClick = () => {
|
||||||
|
if (wishlistItem?.linked_task) {
|
||||||
|
onNavigate?.('task-form', { taskId: wishlistItem.linked_task.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseDetail = () => {
|
||||||
|
setSelectedTaskForDetail(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTaskCompleted = () => {
|
||||||
|
setToastMessage({ text: 'Задача выполнена', type: 'success' })
|
||||||
|
// После выполнения задачи желание тоже завершается, перенаправляем на список
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
if (onNavigate) {
|
||||||
|
onNavigate('wishlist')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnlinkTask = async (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!wishlistItem?.linked_task) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Загружаем текущую задачу
|
||||||
|
const taskResponse = await authFetch(`/api/tasks/${wishlistItem.linked_task.id}`)
|
||||||
|
if (!taskResponse.ok) {
|
||||||
|
throw new Error('Ошибка при загрузке задачи')
|
||||||
|
}
|
||||||
|
const taskData = await taskResponse.json()
|
||||||
|
const task = taskData.task
|
||||||
|
|
||||||
|
// Формируем payload для обновления задачи
|
||||||
|
const payload = {
|
||||||
|
name: task.name,
|
||||||
|
reward_message: task.reward_message || null,
|
||||||
|
progression_base: task.progression_base || null,
|
||||||
|
repetition_period: task.repetition_period || null,
|
||||||
|
repetition_date: task.repetition_date || null,
|
||||||
|
wishlist_id: null, // Отвязываем от желания
|
||||||
|
rewards: (task.rewards || []).map(r => ({
|
||||||
|
position: r.position,
|
||||||
|
project_name: r.project_name,
|
||||||
|
value: r.value,
|
||||||
|
use_progression: r.use_progression || false
|
||||||
|
})),
|
||||||
|
subtasks: (task.subtasks || []).map(st => ({
|
||||||
|
id: st.id,
|
||||||
|
name: st.name || null,
|
||||||
|
reward_message: st.reward_message || null,
|
||||||
|
rewards: (st.rewards || []).map(r => ({
|
||||||
|
position: r.position,
|
||||||
|
project_name: r.project_name,
|
||||||
|
value: r.value,
|
||||||
|
use_progression: r.use_progression || false
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем задачу, отвязывая от желания
|
||||||
|
const updateResponse = await authFetch(`/api/tasks/${wishlistItem.linked_task.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!updateResponse.ok) {
|
||||||
|
const errorData = await updateResponse.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.message || errorData.error || 'Ошибка при отвязке задачи')
|
||||||
|
}
|
||||||
|
|
||||||
|
setToastMessage({ text: 'Задача отвязана от желания', type: 'success' })
|
||||||
|
// Обновляем данные желания
|
||||||
|
fetchWishlistDetail()
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error unlinking task:', err)
|
||||||
|
setToastMessage({ text: err.message || 'Ошибка при отвязке задачи', type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const formatPrice = (price) => {
|
const formatPrice = (price) => {
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -298,17 +401,98 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
|
|||||||
{/* Условия разблокировки */}
|
{/* Условия разблокировки */}
|
||||||
{renderUnlockConditions()}
|
{renderUnlockConditions()}
|
||||||
|
|
||||||
{/* Кнопка завершения */}
|
{/* Связанная задача или кнопки действий */}
|
||||||
{wishlistItem.unlocked && !wishlistItem.completed && (
|
{wishlistItem.unlocked && !wishlistItem.completed && (
|
||||||
<div className="wishlist-detail-actions">
|
<>
|
||||||
<button
|
{wishlistItem.linked_task ? (
|
||||||
onClick={handleComplete}
|
<div className="wishlist-detail-linked-task">
|
||||||
disabled={isCompleting}
|
<div className="linked-task-label-header">Связанная задача:</div>
|
||||||
className="wishlist-detail-complete-button"
|
<div
|
||||||
>
|
className="task-item"
|
||||||
{isCompleting ? 'Завершение...' : 'Завершить'}
|
onClick={handleTaskItemClick}
|
||||||
</button>
|
>
|
||||||
</div>
|
<div className="task-item-content">
|
||||||
|
<div
|
||||||
|
className="task-checkmark"
|
||||||
|
onClick={handleTaskCheckmarkClick}
|
||||||
|
title="Выполнить задачу"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
|
||||||
|
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="task-name-container">
|
||||||
|
<div className="task-name-wrapper">
|
||||||
|
<div className="task-name">
|
||||||
|
{wishlistItem.linked_task.name}
|
||||||
|
</div>
|
||||||
|
{/* Показываем дату только для выполненных задач (next_show_at > сегодня) */}
|
||||||
|
{wishlistItem.linked_task.next_show_at && (() => {
|
||||||
|
const showDate = new Date(wishlistItem.linked_task.next_show_at)
|
||||||
|
// Нормализуем дату: устанавливаем время в 00:00:00 в локальном времени
|
||||||
|
const showDateNormalized = new Date(showDate.getFullYear(), showDate.getMonth(), showDate.getDate())
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const todayNormalized = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||||
|
|
||||||
|
// Показываем только если дата > сегодня
|
||||||
|
if (showDateNormalized.getTime() <= todayNormalized.getTime()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tomorrowNormalized = new Date(todayNormalized)
|
||||||
|
tomorrowNormalized.setDate(tomorrowNormalized.getDate() + 1)
|
||||||
|
|
||||||
|
let dateText
|
||||||
|
if (showDateNormalized.getTime() === tomorrowNormalized.getTime()) {
|
||||||
|
dateText = 'Завтра'
|
||||||
|
} else {
|
||||||
|
dateText = showDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="task-next-show-date">
|
||||||
|
{dateText}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="task-actions">
|
||||||
|
<button
|
||||||
|
className="task-unlink-button"
|
||||||
|
onClick={handleUnlinkTask}
|
||||||
|
title="Отвязать от желания"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="wishlist-detail-actions">
|
||||||
|
<button
|
||||||
|
onClick={handleComplete}
|
||||||
|
disabled={isCompleting}
|
||||||
|
className="wishlist-detail-complete-button"
|
||||||
|
>
|
||||||
|
{isCompleting ? 'Завершение...' : 'Завершить'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateTask}
|
||||||
|
className="wishlist-detail-create-task-button"
|
||||||
|
title="Создать задачу"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 11l3 3L22 4"></path>
|
||||||
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -320,6 +504,20 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
|
|||||||
onClose={() => setToastMessage(null)}
|
onClose={() => setToastMessage(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Модальное окно для деталей задачи */}
|
||||||
|
{selectedTaskForDetail && (
|
||||||
|
<TaskDetail
|
||||||
|
taskId={selectedTaskForDetail}
|
||||||
|
onClose={handleCloseDetail}
|
||||||
|
onRefresh={() => {
|
||||||
|
fetchWishlistDetail()
|
||||||
|
if (onRefresh) onRefresh()
|
||||||
|
}}
|
||||||
|
onTaskCompleted={handleTaskCompleted}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,7 +261,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1500;
|
z-index: 1700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.condition-form {
|
.condition-form {
|
||||||
|
|||||||
@@ -55,15 +55,23 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex }) {
|
|||||||
loadData()
|
loadData()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Загрузка желания при редактировании
|
// Загрузка желания при редактировании или сброс формы при создании
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wishlistId !== undefined && wishlistId !== null && tasks.length > 0 && projects.length > 0) {
|
if (wishlistId !== undefined && wishlistId !== null && tasks.length > 0 && projects.length > 0) {
|
||||||
loadWishlist()
|
loadWishlist()
|
||||||
} else if (wishlistId === undefined || wishlistId === null) {
|
} else if (wishlistId === undefined || wishlistId === null) {
|
||||||
|
// Сбрасываем форму при создании новой задачи
|
||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
}, [wishlistId, tasks, projects])
|
}, [wishlistId, tasks, projects])
|
||||||
|
|
||||||
|
// Сброс формы при размонтировании компонента
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Открываем форму редактирования условия, если передан editConditionIndex
|
// Открываем форму редактирования условия, если передан editConditionIndex
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editConditionIndex !== undefined && editConditionIndex !== null && unlockConditions.length > editConditionIndex) {
|
if (editConditionIndex !== undefined && editConditionIndex !== null && unlockConditions.length > editConditionIndex) {
|
||||||
@@ -110,6 +118,13 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex }) {
|
|||||||
setImageFile(null)
|
setImageFile(null)
|
||||||
setUnlockConditions([])
|
setUnlockConditions([])
|
||||||
setError('')
|
setError('')
|
||||||
|
setShowCropper(false)
|
||||||
|
setCrop({ x: 0, y: 0 })
|
||||||
|
setZoom(1)
|
||||||
|
setCroppedAreaPixels(null)
|
||||||
|
setShowConditionForm(false)
|
||||||
|
setEditingConditionIndex(null)
|
||||||
|
setToastMessage(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Функция для извлечения метаданных из ссылки (по нажатию кнопки)
|
// Функция для извлечения метаданных из ссылки (по нажатию кнопки)
|
||||||
@@ -381,6 +396,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
|
resetForm()
|
||||||
onNavigate?.('wishlist')
|
onNavigate?.('wishlist')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user