Добавлена связь задач с желаниями
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s

This commit is contained in:
poignatov
2026-01-12 18:58:52 +03:00
parent 9fbe2081ed
commit 72a6a3caf9
15 changed files with 983 additions and 73 deletions

View File

@@ -1 +1 @@
3.9.5 3.10.0

View File

@@ -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(&currentWishlistID)
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)
} }

View File

@@ -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.';

View File

@@ -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",

View File

@@ -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>
)} )}

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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;
}

View File

@@ -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 февраля)'

View File

@@ -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}
/> />
)} )}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>
) )
} }

View File

@@ -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 {

View File

@@ -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')
} }