From 636f53eb047950372e126df34fa39aba025ef885 Mon Sep 17 00:00:00 2001 From: poignatov Date: Tue, 10 Mar 2026 22:37:03 +0300 Subject: [PATCH] =?UTF-8?q?6.9.0:=20=D0=97=D0=B0=D0=B4=D0=B0=D1=87=D0=B8-?= =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D1=83=D0=BF=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- VERSION | 2 +- play-life-backend/main.go | 454 ++++++++++++- .../migrations/000029_purchase_tasks.down.sql | 3 + .../migrations/000029_purchase_tasks.up.sql | 24 + play-life-web/package.json | 2 +- play-life-web/src/App.jsx | 49 +- .../src/components/PurchaseScreen.jsx | 640 ++++++++++++++++++ play-life-web/src/components/ShoppingList.jsx | 28 +- play-life-web/src/components/TaskForm.css | 4 +- play-life-web/src/components/TaskForm.jsx | 124 +++- play-life-web/src/components/TaskList.css | 10 + play-life-web/src/components/TaskList.jsx | 44 +- 12 files changed, 1363 insertions(+), 21 deletions(-) create mode 100644 play-life-backend/migrations/000029_purchase_tasks.down.sql create mode 100644 play-life-backend/migrations/000029_purchase_tasks.up.sql create mode 100644 play-life-web/src/components/PurchaseScreen.jsx diff --git a/VERSION b/VERSION index 021c940..97f5781 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.8.3 +6.9.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index ba31150..2700ece 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -338,6 +338,7 @@ type Task struct { RepetitionDate *string `json:"repetition_date,omitempty"` WishlistID *int `json:"wishlist_id,omitempty"` ConfigID *int `json:"config_id,omitempty"` + PurchaseConfigID *int `json:"purchase_config_id,omitempty"` RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями Position *int `json:"position,omitempty"` // Position for subtasks // Дополнительные поля для списка задач (без omitempty чтобы всегда передавались) @@ -384,7 +385,8 @@ type TaskDetail struct { // Test-specific fields (only present if task has config_id) WordsCount *int `json:"words_count,omitempty"` MaxCards *int `json:"max_cards,omitempty"` - DictionaryIDs []int `json:"dictionary_ids,omitempty"` + DictionaryIDs []int `json:"dictionary_ids,omitempty"` + PurchaseBoards []PurchaseBoardInfo `json:"purchase_boards,omitempty"` // Draft fields (only present if draft exists) DraftProgressionValue *float64 `json:"draft_progression_value,omitempty"` DraftSubtasks []DraftSubtask `json:"draft_subtasks,omitempty"` @@ -421,6 +423,20 @@ type TaskRequest struct { WordsCount *int `json:"words_count,omitempty"` MaxCards *int `json:"max_cards,omitempty"` DictionaryIDs []int `json:"dictionary_ids,omitempty"` + // Purchase-specific fields + IsPurchase bool `json:"is_purchase,omitempty"` + PurchaseBoards []PurchaseBoardRequest `json:"purchase_boards,omitempty"` +} + +type PurchaseBoardRequest struct { + BoardID int `json:"board_id"` + GroupName *string `json:"group_name,omitempty"` +} + +type PurchaseBoardInfo struct { + BoardID int `json:"board_id"` + BoardName string `json:"board_name"` + GroupName *string `json:"group_name,omitempty"` } type CompleteTaskRequest struct { @@ -4590,6 +4606,10 @@ func main() { protected.HandleFunc("/api/shopping/invite/{token}", app.getShoppingBoardInviteInfoHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/shopping/invite/{token}/join", app.joinShoppingBoardHandler).Methods("POST", "OPTIONS") + // Purchase tasks + protected.HandleFunc("/api/purchase/boards-info", app.getPurchaseBoardsInfoHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/purchase/items/{purchaseConfigId}", app.getPurchaseItemsHandler).Methods("GET", "OPTIONS") + // Tracking protected.HandleFunc("/api/tracking/stats", app.getTrackingStatsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/tracking/invite", app.createTrackingInviteHandler).Methods("POST", "OPTIONS") @@ -7811,6 +7831,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { t.progression_base, t.wishlist_id, t.config_id, + t.purchase_config_id, t.reward_policy, t.group_name, COALESCE(( @@ -7869,6 +7890,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { var progressionBase sql.NullFloat64 var wishlistID sql.NullInt64 var configID sql.NullInt64 + var purchaseConfigID sql.NullInt64 var rewardPolicy sql.NullString var groupName sql.NullString var projectNames pq.StringArray @@ -7887,6 +7909,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { &progressionBase, &wishlistID, &configID, + &purchaseConfigID, &rewardPolicy, &groupName, &task.SubtasksCount, @@ -7926,6 +7949,10 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { configIDInt := int(configID.Int64) task.ConfigID = &configIDInt } + if purchaseConfigID.Valid { + purchaseConfigIDInt := int(purchaseConfigID.Int64) + task.PurchaseConfigID = &purchaseConfigIDInt + } if rewardPolicy.Valid { task.RewardPolicy = &rewardPolicy.String } @@ -7995,6 +8022,7 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { var repetitionDate sql.NullString var wishlistID sql.NullInt64 var configID sql.NullInt64 + var purchaseConfigID sql.NullInt64 var rewardPolicy sql.NullString var groupName sql.NullString @@ -8007,12 +8035,13 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { COALESCE(repetition_date, '') as repetition_date, wishlist_id, config_id, + purchase_config_id, reward_policy, group_name FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE `, taskID, userID).Scan( - &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID, &rewardPolicy, &groupName, + &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID, &purchaseConfigID, &rewardPolicy, &groupName, ) log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr) @@ -8069,6 +8098,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { configIDInt := int(configID.Int64) task.ConfigID = &configIDInt } + if purchaseConfigID.Valid { + purchaseConfigIDInt := int(purchaseConfigID.Int64) + task.PurchaseConfigID = &purchaseConfigIDInt + } if rewardPolicy.Valid { task.RewardPolicy = &rewardPolicy.String } @@ -8343,6 +8376,35 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { } } + // Если задача - закупка (есть purchase_config_id), загружаем данные конфигурации + if purchaseConfigID.Valid { + boardRows, err := a.DB.Query(` + SELECT pcb.board_id, sb.name, pcb.group_name + FROM purchase_config_boards pcb + JOIN shopping_boards sb ON sb.id = pcb.board_id + WHERE pcb.purchase_config_id = $1 + `, purchaseConfigID.Int64) + if err == nil { + defer boardRows.Close() + purchaseBoards := make([]PurchaseBoardInfo, 0) + for boardRows.Next() { + var info PurchaseBoardInfo + var groupName sql.NullString + if err := boardRows.Scan(&info.BoardID, &info.BoardName, &groupName); err == nil { + if groupName.Valid { + info.GroupName = &groupName.String + } + purchaseBoards = append(purchaseBoards, info) + } + } + if len(purchaseBoards) > 0 { + response.PurchaseBoards = purchaseBoards + } + } else { + log.Printf("Error loading purchase config for task %d: %v", taskID, err) + } + } + log.Printf("Task %d: Sending response with auto_complete = %v (task.AutoComplete = %v)", taskID, response.Task.AutoComplete, task.AutoComplete) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) @@ -8816,6 +8878,45 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Created test config %d for task %d", configID, taskID) } + // Если это закупка, создаем конфигурацию + if req.IsPurchase { + if len(req.PurchaseBoards) == 0 { + sendErrorWithCORS(w, "At least one board is required for purchase tasks", http.StatusBadRequest) + return + } + + var purchaseConfigID int + err = tx.QueryRow(` + INSERT INTO purchase_configs (user_id) VALUES ($1) RETURNING id + `, userID).Scan(&purchaseConfigID) + if err != nil { + log.Printf("Error creating purchase config: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating purchase config: %v", err), http.StatusInternalServerError) + return + } + + for _, pb := range req.PurchaseBoards { + _, err = tx.Exec(` + INSERT INTO purchase_config_boards (purchase_config_id, board_id, group_name) + VALUES ($1, $2, $3) + `, purchaseConfigID, pb.BoardID, pb.GroupName) + if err != nil { + log.Printf("Error linking board %d to purchase config: %v", pb.BoardID, err) + sendErrorWithCORS(w, fmt.Sprintf("Error linking board to purchase config: %v", err), http.StatusInternalServerError) + return + } + } + + _, err = tx.Exec("UPDATE tasks SET purchase_config_id = $1 WHERE id = $2", purchaseConfigID, taskID) + if err != nil { + log.Printf("Error linking purchase config to task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error linking purchase config to task: %v", err), http.StatusInternalServerError) + return + } + + log.Printf("Created purchase config %d for task %d", purchaseConfigID, taskID) + } + // Коммитим транзакцию if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) @@ -9363,6 +9464,90 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) { } } + // Обработка конфигурации закупки + var currentPurchaseConfigID sql.NullInt64 + err = tx.QueryRow("SELECT purchase_config_id FROM tasks WHERE id = $1", taskID).Scan(¤tPurchaseConfigID) + if err != nil { + log.Printf("Error getting current purchase_config_id: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error getting task purchase config: %v", err), http.StatusInternalServerError) + return + } + + if req.IsPurchase { + if len(req.PurchaseBoards) == 0 { + sendErrorWithCORS(w, "At least one board is required for purchase tasks", http.StatusBadRequest) + return + } + + if currentPurchaseConfigID.Valid { + // Обновляем существующую конфигурацию - удаляем старые связи и создаем новые + _, err = tx.Exec("DELETE FROM purchase_config_boards WHERE purchase_config_id = $1", currentPurchaseConfigID.Int64) + if err != nil { + log.Printf("Error deleting purchase config boards: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error updating purchase config: %v", err), http.StatusInternalServerError) + return + } + + for _, pb := range req.PurchaseBoards { + _, err = tx.Exec(` + INSERT INTO purchase_config_boards (purchase_config_id, board_id, group_name) + VALUES ($1, $2, $3) + `, currentPurchaseConfigID.Int64, pb.BoardID, pb.GroupName) + if err != nil { + log.Printf("Error linking board %d to purchase config: %v", pb.BoardID, err) + sendErrorWithCORS(w, fmt.Sprintf("Error linking board to purchase config: %v", err), http.StatusInternalServerError) + return + } + } + } else { + // Создаем новую конфигурацию закупки + var newPurchaseConfigID int + err = tx.QueryRow(` + INSERT INTO purchase_configs (user_id) VALUES ($1) RETURNING id + `, userID).Scan(&newPurchaseConfigID) + if err != nil { + log.Printf("Error creating purchase config: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating purchase config: %v", err), http.StatusInternalServerError) + return + } + + for _, pb := range req.PurchaseBoards { + _, err = tx.Exec(` + INSERT INTO purchase_config_boards (purchase_config_id, board_id, group_name) + VALUES ($1, $2, $3) + `, newPurchaseConfigID, pb.BoardID, pb.GroupName) + if err != nil { + log.Printf("Error linking board %d to purchase config: %v", pb.BoardID, err) + sendErrorWithCORS(w, fmt.Sprintf("Error linking board to purchase config: %v", err), http.StatusInternalServerError) + return + } + } + + _, err = tx.Exec("UPDATE tasks SET purchase_config_id = $1 WHERE id = $2", newPurchaseConfigID, taskID) + if err != nil { + log.Printf("Error linking purchase config to task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error linking purchase config to task: %v", err), http.StatusInternalServerError) + return + } + } + } else if currentPurchaseConfigID.Valid { + // Задача перестала быть закупкой - удаляем конфигурацию + _, err = tx.Exec("DELETE FROM purchase_config_boards WHERE purchase_config_id = $1", currentPurchaseConfigID.Int64) + if err != nil { + log.Printf("Error deleting purchase config boards: %v", err) + } + _, err = tx.Exec("DELETE FROM purchase_configs WHERE id = $1", currentPurchaseConfigID.Int64) + if err != nil { + log.Printf("Error deleting purchase config: %v", err) + } + _, err = tx.Exec("UPDATE tasks SET purchase_config_id = NULL WHERE id = $1", taskID) + if err != nil { + log.Printf("Error unlinking purchase config from task: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error unlinking purchase config from task: %v", err), http.StatusInternalServerError) + return + } + } + // Коммитим транзакцию if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) @@ -19269,3 +19454,268 @@ func (a *App) deleteShoppingItemHistoryHandler(w http.ResponseWriter, r *http.Re "success": true, }) } + +// getPurchaseBoardsInfoHandler возвращает доски пользователя с их группами для формы закупок +func (a *App) getPurchaseBoardsInfoHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Получаем все доски пользователя (свои и расшаренные) + boardRows, err := a.DB.Query(` + SELECT sb.id, sb.name + FROM shopping_boards sb + WHERE sb.deleted = FALSE AND ( + sb.owner_id = $1 + OR EXISTS (SELECT 1 FROM shopping_board_members sbm WHERE sbm.board_id = sb.id AND sbm.user_id = $1) + ) + ORDER BY sb.name + `, userID) + if err != nil { + log.Printf("Error getting boards for purchase config: %v", err) + sendErrorWithCORS(w, "Error getting boards", http.StatusInternalServerError) + return + } + defer boardRows.Close() + + type BoardInfo struct { + ID int `json:"id"` + Name string `json:"name"` + Groups []string `json:"groups"` + } + + boards := make([]BoardInfo, 0) + boardIDs := make([]int, 0) + for boardRows.Next() { + var board BoardInfo + if err := boardRows.Scan(&board.ID, &board.Name); err != nil { + log.Printf("Error scanning board: %v", err) + continue + } + board.Groups = make([]string, 0) + boards = append(boards, board) + boardIDs = append(boardIDs, board.ID) + } + + // Получаем группы для каждой доски + for i, boardID := range boardIDs { + groupRows, err := a.DB.Query(` + SELECT DISTINCT group_name + FROM shopping_items + WHERE board_id = $1 AND deleted = FALSE AND group_name IS NOT NULL AND group_name != '' + ORDER BY group_name + `, boardID) + if err != nil { + log.Printf("Error getting groups for board %d: %v", boardID, err) + continue + } + for groupRows.Next() { + var groupName string + if err := groupRows.Scan(&groupName); err == nil { + boards[i].Groups = append(boards[i].Groups, groupName) + } + } + groupRows.Close() + + // Проверяем наличие товаров без группы + var hasUngrouped bool + a.DB.QueryRow(` + SELECT EXISTS(SELECT 1 FROM shopping_items WHERE board_id = $1 AND deleted = FALSE AND (group_name IS NULL OR group_name = '')) + `, boardID).Scan(&hasUngrouped) + if hasUngrouped { + boards[i].Groups = append(boards[i].Groups, "") + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "boards": boards, + }) +} + +// getPurchaseItemsHandler возвращает товары для конфигурации закупки +func (a *App) getPurchaseItemsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + purchaseConfigID, err := strconv.Atoi(vars["purchaseConfigId"]) + if err != nil { + sendErrorWithCORS(w, "Invalid purchase config ID", http.StatusBadRequest) + return + } + + // Проверяем что конфиг принадлежит пользователю + var configUserID int + err = a.DB.QueryRow("SELECT user_id FROM purchase_configs WHERE id = $1", purchaseConfigID).Scan(&configUserID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Purchase config not found", http.StatusNotFound) + return + } + if err != nil { + sendErrorWithCORS(w, "Error checking purchase config", http.StatusInternalServerError) + return + } + if configUserID != userID { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + + // Получаем связанные доски и группы + boardRows, err := a.DB.Query(` + SELECT board_id, group_name + FROM purchase_config_boards + WHERE purchase_config_id = $1 + `, purchaseConfigID) + if err != nil { + sendErrorWithCORS(w, "Error getting purchase config boards", http.StatusInternalServerError) + return + } + defer boardRows.Close() + + type boardFilter struct { + BoardID int + GroupName *string + } + filters := make([]boardFilter, 0) + for boardRows.Next() { + var f boardFilter + var groupName sql.NullString + if err := boardRows.Scan(&f.BoardID, &groupName); err == nil { + if groupName.Valid { + f.GroupName = &groupName.String + } + filters = append(filters, f) + } + } + + if len(filters) == 0 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]ShoppingItem{}) + return + } + + // Строим динамический запрос для получения товаров + items := make([]ShoppingItem, 0) + for _, f := range filters { + var query string + var args []interface{} + if f.GroupName != nil { + if *f.GroupName == "" { + // Пустая строка означает "товары без группы" + query = ` + SELECT + si.id, si.user_id, si.board_id, si.author_id, si.name, si.description, si.group_name, + si.volume_base, si.repetition_period::text, si.next_show_at, si.completed, + si.last_completed_at, si.created_at + FROM shopping_items si + WHERE si.board_id = $1 AND si.deleted = FALSE AND (si.group_name IS NULL OR si.group_name = '') + ORDER BY si.created_at ASC + ` + args = []interface{}{f.BoardID} + } else { + query = ` + SELECT + si.id, si.user_id, si.board_id, si.author_id, si.name, si.description, si.group_name, + si.volume_base, si.repetition_period::text, si.next_show_at, si.completed, + si.last_completed_at, si.created_at + FROM shopping_items si + WHERE si.board_id = $1 AND si.deleted = FALSE AND si.group_name = $2 + ORDER BY si.created_at ASC + ` + args = []interface{}{f.BoardID, *f.GroupName} + } + } else { + query = ` + SELECT + si.id, si.user_id, si.board_id, si.author_id, si.name, si.description, si.group_name, + si.volume_base, si.repetition_period::text, si.next_show_at, si.completed, + si.last_completed_at, si.created_at + FROM shopping_items si + WHERE si.board_id = $1 AND si.deleted = FALSE + ORDER BY si.created_at ASC + ` + args = []interface{}{f.BoardID} + } + + rows, err := a.DB.Query(query, args...) + if err != nil { + log.Printf("Error getting purchase items for board %d: %v", f.BoardID, err) + continue + } + + for rows.Next() { + var item ShoppingItem + var description sql.NullString + var groupName sql.NullString + var repetitionPeriod sql.NullString + var nextShowAt sql.NullTime + var lastCompletedAt sql.NullTime + var createdAt time.Time + + err := rows.Scan( + &item.ID, &item.UserID, &item.BoardID, &item.AuthorID, &item.Name, &description, &groupName, + &item.VolumeBase, &repetitionPeriod, &nextShowAt, &item.Completed, + &lastCompletedAt, &createdAt, + ) + if err != nil { + log.Printf("Error scanning purchase item: %v", err) + continue + } + + if description.Valid { + item.Description = &description.String + } + if groupName.Valid { + item.GroupName = &groupName.String + } + if repetitionPeriod.Valid { + item.RepetitionPeriod = &repetitionPeriod.String + } + if nextShowAt.Valid { + s := nextShowAt.Time.Format(time.RFC3339) + item.NextShowAt = &s + } + if lastCompletedAt.Valid { + s := lastCompletedAt.Time.Format(time.RFC3339) + item.LastCompletedAt = &s + } + item.CreatedAt = createdAt.Format(time.RFC3339) + + items = append(items, item) + } + rows.Close() + } + + // Дедупликация (товар может попасть из нескольких фильтров) + seen := make(map[int]bool) + uniqueItems := make([]ShoppingItem, 0, len(items)) + for _, item := range items { + if !seen[item.ID] { + seen[item.ID] = true + uniqueItems = append(uniqueItems, item) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(uniqueItems) +} diff --git a/play-life-backend/migrations/000029_purchase_tasks.down.sql b/play-life-backend/migrations/000029_purchase_tasks.down.sql new file mode 100644 index 0000000..f8c81f2 --- /dev/null +++ b/play-life-backend/migrations/000029_purchase_tasks.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE tasks DROP COLUMN IF EXISTS purchase_config_id; +DROP TABLE IF EXISTS purchase_config_boards; +DROP TABLE IF EXISTS purchase_configs; diff --git a/play-life-backend/migrations/000029_purchase_tasks.up.sql b/play-life-backend/migrations/000029_purchase_tasks.up.sql new file mode 100644 index 0000000..c7f023c --- /dev/null +++ b/play-life-backend/migrations/000029_purchase_tasks.up.sql @@ -0,0 +1,24 @@ +-- Purchase task configurations +CREATE TABLE purchase_configs ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_purchase_configs_user_id ON purchase_configs(user_id); + +-- Purchase config board/group associations +CREATE TABLE purchase_config_boards ( + id SERIAL PRIMARY KEY, + purchase_config_id INTEGER NOT NULL REFERENCES purchase_configs(id) ON DELETE CASCADE, + board_id INTEGER NOT NULL REFERENCES shopping_boards(id) ON DELETE CASCADE, + group_name VARCHAR(255), + UNIQUE (purchase_config_id, board_id, group_name) +); + +CREATE INDEX idx_purchase_config_boards_config_id ON purchase_config_boards(purchase_config_id); +CREATE INDEX idx_purchase_config_boards_board_id ON purchase_config_boards(board_id); + +-- Add purchase_config_id to tasks +ALTER TABLE tasks ADD COLUMN purchase_config_id INTEGER REFERENCES purchase_configs(id) ON DELETE SET NULL; +CREATE INDEX idx_tasks_purchase_config_id ON tasks(purchase_config_id); diff --git a/play-life-web/package.json b/play-life-web/package.json index 7f105c9..0e3bc43 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "6.8.3", + "version": "6.9.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index 46c8b24..dc1956d 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -19,6 +19,7 @@ import ShoppingItemForm from './components/ShoppingItemForm' import ShoppingBoardForm from './components/ShoppingBoardForm' import ShoppingBoardJoinPreview from './components/ShoppingBoardJoinPreview' import ShoppingItemHistory from './components/ShoppingItemHistory' +import PurchaseScreen from './components/PurchaseScreen' import TodoistIntegration from './components/TodoistIntegration' import TelegramIntegration from './components/TelegramIntegration' import FitbitIntegration from './components/FitbitIntegration' @@ -35,7 +36,7 @@ const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b' // Определяем основные табы (без крестика) и глубокие табы (с крестиком) const mainTabs = ['current', 'tasks', 'wishlist', 'shopping', 'profile'] -const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join', 'shopping-item-history'] +const deepTabs = ['add-words', 'test', 'purchase', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join', 'shopping-item-history'] /** * Гарантирует базовую запись истории для главного экрана перед глубоким табом. @@ -87,6 +88,7 @@ function AppContent() { 'shopping-board-form': false, 'shopping-board-join': false, 'shopping-item-history': false, + purchase: false, }) // Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок) @@ -117,6 +119,7 @@ function AppContent() { 'shopping-board-form': false, 'shopping-board-join': false, 'shopping-item-history': false, + purchase: false, }) // Параметры для навигации между вкладками @@ -295,7 +298,7 @@ function AppContent() { // Проверяем URL только для глубоких табов const tabFromUrl = urlParams.get('tab') - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join', 'shopping-item-history'] + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'purchase', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join', 'shopping-item-history'] if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl) && window.history.length > 1) { // Восстанавливаем глубокий таб из URL только если есть история (не рестарт PWA) @@ -792,7 +795,7 @@ function AppContent() { return } - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join', 'shopping-item-history'] + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'purchase', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite', 'shopping', 'shopping-item-form', 'shopping-board-form', 'shopping-board-join', 'shopping-item-history'] // Проверяем state текущей записи истории (куда мы вернулись) if (event.state && event.state.tab) { @@ -909,7 +912,7 @@ function AppContent() { { // Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров // task-form может иметь taskId (редактирование), wishlistId (создание из желания), returnTo (возврат после создания), или isTest (создание теста) - const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === undefined && params.isTest === undefined + const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === undefined && params.isTest === undefined && params.isPurchase === undefined // Проверяем, что boardId не null и не undefined (null означает "нет доски", но это валидное значение) const hasBoardId = params.boardId !== null && params.boardId !== undefined const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined && params.newTaskId === undefined && !hasBoardId @@ -976,7 +979,7 @@ function AppContent() { } // Обновляем список задач при возврате из экрана редактирования или теста // Используем фоновую загрузку, чтобы не показывать индикатор загрузки - if ((activeTab === 'task-form' || activeTab === 'test') && tab === 'tasks') { + if ((activeTab === 'task-form' || activeTab === 'test' || activeTab === 'purchase') && tab === 'tasks') { fetchTasksData(true) } // Сохраняем предыдущий таб при открытии wishlist-form или wishlist-detail @@ -1037,6 +1040,11 @@ function AppContent() { handleNavigate('task-form', { taskId: undefined, isTest: true }) } + const handleAddPurchase = () => { + setShowAddModal(false) + handleNavigate('task-form', { taskId: undefined, isPurchase: true }) + } + // Обработчик навигации для компонентов const handleNavigate = (tab, params = {}, options = {}) => { handleTabChange(tab, params, options) @@ -1116,7 +1124,7 @@ function AppContent() { } // Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов) - const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'fitbit-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite' || activeTab === 'shopping-item-form' || activeTab === 'shopping-board-form' || activeTab === 'shopping-board-join' || activeTab === 'shopping-item-history' + const isFullscreenTab = activeTab === 'test' || activeTab === 'purchase' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'fitbit-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite' || activeTab === 'shopping-item-form' || activeTab === 'shopping-board-form' || activeTab === 'shopping-board-join' || activeTab === 'shopping-item-history' // Функция для получения классов скролл-контейнера для каждого таба // Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла @@ -1145,7 +1153,7 @@ function AppContent() { if (tabName === 'current') { return 'max-w-7xl mx-auto p-4 md:p-6' } - if (tabName === 'full' || tabName === 'priorities' || tabName === 'dictionaries' || tabName === 'words' || tabName === 'shopping-item-history') { + if (tabName === 'full' || tabName === 'priorities' || tabName === 'dictionaries' || tabName === 'words' || tabName === 'shopping-item-history' || tabName === 'purchase') { return 'max-w-7xl mx-auto px-4 md:px-8 py-0' } // Fullscreen табы без отступов @@ -1257,7 +1265,7 @@ function AppContent() { {loadedTabs.test && (
- )} + {loadedTabs.purchase && ( +
+
+ +
+
+ )} + {loadedTabs.tasks && (
@@ -1293,6 +1314,7 @@ function AppContent() { taskId={tabParams.taskId} wishlistId={tabParams.wishlistId} isTest={tabParams.isTest} + isPurchase={tabParams.isPurchase} returnTo={tabParams.returnTo} returnWishlistId={tabParams.returnWishlistId} /> @@ -1787,6 +1809,17 @@ function AppContent() { Тест +
diff --git a/play-life-web/src/components/PurchaseScreen.jsx b/play-life-web/src/components/PurchaseScreen.jsx new file mode 100644 index 0000000..b7c7ffc --- /dev/null +++ b/play-life-web/src/components/PurchaseScreen.jsx @@ -0,0 +1,640 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react' +import { createPortal } from 'react-dom' +import { useAuth } from './auth/AuthContext' +import ShoppingItemDetail from './ShoppingItemDetail' +import Toast from './Toast' +import { DayPicker } from 'react-day-picker' +import { ru } from 'react-day-picker/locale' +import 'react-day-picker/style.css' +import './TaskList.css' +import './ShoppingList.css' + +// Форматирование даты в YYYY-MM-DD (локальное время) +const formatDateToLocal = (date) => { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +// Форматирование даты для отображения +const formatDateForDisplay = (dateStr) => { + if (!dateStr) return '' + const date = new Date(dateStr) + if (isNaN(date.getTime())) return '' + + const now = new Date() + now.setHours(0, 0, 0, 0) + const target = new Date(date.getFullYear(), date.getMonth(), date.getDate()) + const diffDays = Math.round((target - now) / (1000 * 60 * 60 * 24)) + + if (diffDays === 0) return 'Сегодня' + if (diffDays === 1) return 'Завтра' + + const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', + 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'] + return `${date.getDate()} ${months[date.getMonth()]}` +} + +const calculateNextDateFromRepetitionPeriod = (periodStr) => { + if (!periodStr) return null + const match = periodStr.match(/(\d+)\s*(day|week|mon|year)/i) + if (!match) return null + const value = parseInt(match[1], 10) + const unit = match[2].toLowerCase() + const next = new Date() + next.setHours(0, 0, 0, 0) + if (unit.startsWith('day')) next.setDate(next.getDate() + value) + else if (unit.startsWith('week')) next.setDate(next.getDate() + value * 7) + else if (unit.startsWith('mon')) next.setMonth(next.getMonth() + value) + else if (unit.startsWith('year')) next.setFullYear(next.getFullYear() + value) + return next +} + +function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) { + const { authFetch } = useAuth() + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) + const [selectedItemForDetail, setSelectedItemForDetail] = useState(null) + const [selectedItemForPostpone, setSelectedItemForPostpone] = useState(null) + const [postponeDate, setPostponeDate] = useState('') + const [isPostponing, setIsPostponing] = useState(false) + const [toast, setToast] = useState(null) + const [expandedFuture, setExpandedFuture] = useState({}) + const [isCompleting, setIsCompleting] = useState(false) + const historyPushedForDetailRef = useRef(false) + const historyPushedForPostponeRef = useRef(false) + const selectedItemForDetailRef = useRef(null) + const selectedItemForPostponeRef = useRef(null) + + const fetchItems = async () => { + if (!purchaseConfigId) return + try { + setLoading(true) + setError(false) + const response = await authFetch(`/api/purchase/items/${purchaseConfigId}`) + if (response.ok) { + const data = await response.json() + setItems(Array.isArray(data) ? data : []) + } else { + setError(true) + } + } catch (err) { + console.error('Error loading purchase items:', err) + setError(true) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchItems() + }, [purchaseConfigId]) + + const handleRefresh = () => { + fetchItems() + } + + const handleClose = () => { + onNavigate?.('tasks') + } + + const handleCompleteTask = async () => { + if (!taskId || isCompleting) return + setIsCompleting(true) + try { + const response = await authFetch(`/api/tasks/${taskId}/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }) + if (response.ok) { + setToast({ message: 'Задача выполнена', type: 'success' }) + setTimeout(() => onNavigate?.('tasks'), 500) + } else { + const errorData = await response.json().catch(() => ({})) + setToast({ message: errorData.error || 'Ошибка выполнения', type: 'error' }) + } + } catch (err) { + setToast({ message: 'Ошибка выполнения', type: 'error' }) + } finally { + setIsCompleting(false) + } + } + + // Синхронизация refs для диалогов + useEffect(() => { + selectedItemForDetailRef.current = selectedItemForDetail + selectedItemForPostponeRef.current = selectedItemForPostpone + }, [selectedItemForDetail, selectedItemForPostpone]) + + // Пуш в историю при открытии модалок и обработка popstate + useEffect(() => { + if (selectedItemForPostpone && !historyPushedForPostponeRef.current) { + window.history.pushState({ modalOpen: true, type: 'purchase-postpone' }, '', window.location.href) + historyPushedForPostponeRef.current = true + } else if (!selectedItemForPostpone) { + historyPushedForPostponeRef.current = false + } + + if (selectedItemForDetail && !historyPushedForDetailRef.current) { + window.history.pushState({ modalOpen: true, type: 'purchase-detail' }, '', window.location.href) + historyPushedForDetailRef.current = true + } else if (!selectedItemForDetail) { + historyPushedForDetailRef.current = false + } + + if (!selectedItemForDetail && !selectedItemForPostpone) return + + const handlePopState = () => { + const currentDetail = selectedItemForDetailRef.current + const currentPostpone = selectedItemForPostponeRef.current + + if (currentPostpone) { + setSelectedItemForPostpone(null) + setPostponeDate('') + historyPushedForPostponeRef.current = false + return + } + + if (currentDetail) { + setSelectedItemForDetail(null) + historyPushedForDetailRef.current = false + } + } + + window.addEventListener('popstate', handlePopState) + return () => { + window.removeEventListener('popstate', handlePopState) + } + }, [selectedItemForDetail, selectedItemForPostpone]) + + // Фильтрация и группировка + const groupedItems = useMemo(() => { + const now = new Date() + now.setHours(0, 0, 0, 0) + const todayEnd = new Date(now) + todayEnd.setHours(23, 59, 59, 999) + + const groups = {} + + items.forEach(item => { + const groupKey = item.group_name || 'Остальные' + if (!groups[groupKey]) { + groups[groupKey] = { active: [], future: [] } + } + + if (!item.next_show_at) { + groups[groupKey].future.push(item) + return + } + const showAt = new Date(item.next_show_at) + if (showAt > todayEnd) { + groups[groupKey].future.push(item) + return + } + groups[groupKey].active.push(item) + }) + + Object.values(groups).forEach(group => { + group.future.sort((a, b) => { + if (!a.next_show_at) return 1 + if (!b.next_show_at) return -1 + return new Date(a.next_show_at) - new Date(b.next_show_at) + }) + }) + + return groups + }, [items]) + + const groupNames = useMemo(() => { + const names = Object.keys(groupedItems) + return names.sort((a, b) => { + const groupA = groupedItems[a] + const groupB = groupedItems[b] + const hasActiveA = groupA.active.length > 0 + const hasActiveB = groupB.active.length > 0 + + if (hasActiveA && !hasActiveB) return -1 + if (!hasActiveA && hasActiveB) return 1 + + if (a === 'Остальные') return 1 + if (b === 'Остальные') return -1 + return a.localeCompare(b, 'ru') + }) + }, [groupedItems]) + + const toggleFuture = (groupName) => { + setExpandedFuture(prev => ({ + ...prev, + [groupName]: !prev[groupName] + })) + } + + const handleCloseDetail = () => { + if (historyPushedForDetailRef.current) { + window.history.back() + } else { + setSelectedItemForDetail(null) + } + } + + const handlePostponeClose = () => { + if (historyPushedForPostponeRef.current) { + window.history.back() + } else { + setSelectedItemForPostpone(null) + setPostponeDate('') + } + } + + const handlePostponeSubmitWithDate = async (dateStr) => { + if (!selectedItemForPostpone || !dateStr) return + setIsPostponing(true) + try { + const nextShowAt = new Date(dateStr + 'T00:00:00') + const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ next_show_at: nextShowAt.toISOString() }) + }) + if (res.ok) { + setToast({ message: 'Дата обновлена', type: 'success' }) + handleRefresh() + handlePostponeClose() + } else { + setToast({ message: 'Ошибка переноса', type: 'error' }) + } + } catch (err) { + setToast({ message: 'Ошибка переноса', type: 'error' }) + } finally { + setIsPostponing(false) + } + } + + const handleDateSelect = (date) => { + if (date) { + setPostponeDate(formatDateToLocal(date)) + } + } + + const handleDayClick = (date) => { + if (date) { + handlePostponeSubmitWithDate(formatDateToLocal(date)) + } + } + + const handleTodayClick = () => { + handlePostponeSubmitWithDate(formatDateToLocal(new Date())) + } + + const handleTomorrowClick = () => { + const tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + handlePostponeSubmitWithDate(formatDateToLocal(tomorrow)) + } + + const handleWithoutDateClick = async () => { + if (!selectedItemForPostpone) return + setIsPostponing(true) + try { + const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ next_show_at: null }) + }) + if (res.ok) { + setToast({ message: 'Дата убрана', type: 'success' }) + handleRefresh() + handlePostponeClose() + } + } catch (err) { + setToast({ message: 'Ошибка', type: 'error' }) + } finally { + setIsPostponing(false) + } + } + + const renderItem = (item) => { + let dateDisplay = null + if (item.next_show_at) { + const itemDate = new Date(item.next_show_at) + const now = new Date() + now.setHours(0, 0, 0, 0) + const target = new Date(itemDate.getFullYear(), itemDate.getMonth(), itemDate.getDate()) + if (target > now) { + dateDisplay = formatDateForDisplay(item.next_show_at) + } + } + + return ( +
setSelectedItemForDetail(item.id)} + > +
+
{ + e.stopPropagation() + setSelectedItemForDetail(item.id) + }} + title="Выполнить" + > + + + + +
+
+
+
{item.name}
+ {dateDisplay && ( +
{dateDisplay}
+ )} +
+
+
+ +
+
+
+ ) + } + + return ( +
+ +

{taskName || 'Закупка'}

+ + {loading && items.length === 0 && ( +
+
+
+ )} + + {error && ( +
+

Ошибка загрузки

+ +
+ )} + + {!loading && !error && items.length === 0 && ( +
+

Нет товаров

+
+ )} + + {groupNames.map(groupName => { + const group = groupedItems[groupName] + const hasActive = group.active.length > 0 + const hasFuture = group.future.length > 0 + const isFutureExpanded = expandedFuture[groupName] + + return ( +
+
toggleFuture(groupName) : undefined} + > +

{groupName}

+ {hasFuture ? ( + + ) : ( +
+ +
+ )} +
+ + {hasActive && ( +
+ {group.active.map(item => renderItem(item))} +
+ )} + + {hasFuture && isFutureExpanded && ( +
+ {group.future.map(item => renderItem(item))} +
+ )} +
+ ) + })} + + {/* Кнопка завершения задачи — фиксированная внизу */} + {!loading && !error && taskId && createPortal( +
+ +
, + document.body + )} + + {/* Модалка выполнения */} + {selectedItemForDetail && ( + setToast({ message: 'Товар выполнен', type: 'success' })} + onNavigate={onNavigate} + /> + )} + + {/* Модалка переноса */} + {selectedItemForPostpone && (() => { + const todayStr = formatDateToLocal(new Date()) + const tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + const tomorrowStr = formatDateToLocal(tomorrow) + + let nextShowAtStr = null + if (selectedItemForPostpone.next_show_at) { + const nextShowAtDate = new Date(selectedItemForPostpone.next_show_at) + nextShowAtStr = formatDateToLocal(nextShowAtDate) + } + + const isTomorrow = nextShowAtStr === tomorrowStr + const showTodayChip = !nextShowAtStr || nextShowAtStr > todayStr + + const item = selectedItemForPostpone + let plannedDate + const now = new Date() + now.setHours(0, 0, 0, 0) + if (item.repetition_period) { + const nextDate = calculateNextDateFromRepetitionPeriod(item.repetition_period) + if (nextDate) plannedDate = nextDate + } + if (!plannedDate) { + plannedDate = new Date(now) + plannedDate.setDate(plannedDate.getDate() + 1) + } + plannedDate.setHours(0, 0, 0, 0) + const plannedDateStr = formatDateToLocal(plannedDate) + const plannedNorm = plannedDateStr.slice(0, 10) + const nextShowNorm = nextShowAtStr ? String(nextShowAtStr).slice(0, 10) : '' + const isCurrentDatePlanned = plannedNorm && nextShowNorm && plannedNorm === nextShowNorm + + const modalContent = ( +
+
e.stopPropagation()}> +
+

{selectedItemForPostpone.name}

+ +
+
+
+ { + const today = new Date() + today.setHours(0, 0, 0, 0) + return today + })() }} + locale={ru} + /> +
+
+ {showTodayChip && ( + + )} + {!isTomorrow && ( + + )} + {!isCurrentDatePlanned && ( + + )} + {nextShowAtStr && ( + + )} +
+
+
+
+ ) + return typeof document !== 'undefined' + ? createPortal(modalContent, document.body) + : modalContent + })()} + + {toast && ( + setToast(null)} + /> + )} +
+ ) +} + +export default PurchaseScreen diff --git a/play-life-web/src/components/ShoppingList.jsx b/play-life-web/src/components/ShoppingList.jsx index 3fef0ef..15479e7 100644 --- a/play-life-web/src/components/ShoppingList.jsx +++ b/play-life-web/src/components/ShoppingList.jsx @@ -398,6 +398,32 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia } } + const openPostpone = (item) => { + setSelectedItemForPostpone(item) + // Предвыбираем дату "по плану" если она не совпадает с текущей next_show_at + const now2 = new Date() + now2.setHours(0, 0, 0, 0) + let planned + if (item.repetition_period) { + planned = calculateNextDateFromRepetitionPeriod(item.repetition_period) + } + if (!planned) { + planned = new Date(now2) + planned.setDate(planned.getDate() + 1) + } + planned.setHours(0, 0, 0, 0) + const plannedStr = formatDateToLocal(planned) + let nextShowStr = null + if (item.next_show_at) { + nextShowStr = formatDateToLocal(new Date(item.next_show_at)) + } + if (plannedStr !== nextShowStr) { + setPostponeDate(plannedStr) + } else { + setPostponeDate('') + } + } + // Модалка переноса const handlePostponeClose = () => { if (historyPushedForPostponeRef.current) { @@ -619,7 +645,7 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia className="task-postpone-button" onClick={(e) => { e.stopPropagation() - setSelectedItemForPostpone(item) + openPostpone(item) }} title="Перенести" > diff --git a/play-life-web/src/components/TaskForm.css b/play-life-web/src/components/TaskForm.css index bd1abfd..6964200 100644 --- a/play-life-web/src/components/TaskForm.css +++ b/play-life-web/src/components/TaskForm.css @@ -513,7 +513,7 @@ display: flex; align-items: center; gap: 0.5rem; - padding: 0.5rem; + padding: 0.25rem 0.5rem; border-radius: 0.25rem; cursor: pointer; transition: background-color 0.2s; @@ -526,6 +526,8 @@ .test-dictionary-item input[type="checkbox"] { width: 18px; height: 18px; + flex-shrink: 0; + margin: 0; accent-color: #3498db; } diff --git a/play-life-web/src/components/TaskForm.jsx b/play-life-web/src/components/TaskForm.jsx index 585c7e3..894b39e 100644 --- a/play-life-web/src/components/TaskForm.jsx +++ b/play-life-web/src/components/TaskForm.jsx @@ -8,7 +8,7 @@ import './TaskForm.css' const API_URL = '/api/tasks' const PROJECTS_API_URL = '/projects' -function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = false, returnTo, returnWishlistId }) { +function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = false, isPurchase: isPurchaseFromProps = false, returnTo, returnWishlistId }) { const { authFetch } = useAuth() const [name, setName] = useState('') const [progressionBase, setProgressionBase] = useState('') @@ -35,6 +35,10 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa const [maxCards, setMaxCards] = useState('') const [selectedDictionaryIDs, setSelectedDictionaryIDs] = useState([]) const [availableDictionaries, setAvailableDictionaries] = useState([]) + // Purchase-specific state + const [isPurchase, setIsPurchase] = useState(isPurchaseFromProps) + const [availableBoards, setAvailableBoards] = useState([]) + const [selectedPurchaseBoards, setSelectedPurchaseBoards] = useState([]) const debounceTimer = useRef(null) // Загрузка проектов для автокомплита @@ -85,6 +89,22 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa loadDictionaries() }, []) + // Загрузка досок для закупок + useEffect(() => { + const loadBoards = async () => { + try { + const response = await authFetch('/api/purchase/boards-info') + if (response.ok) { + const data = await response.json() + setAvailableBoards(Array.isArray(data.boards) ? data.boards : []) + } + } catch (err) { + console.error('Error loading boards for purchase:', err) + } + } + loadBoards() + }, []) + // Функция сброса формы const resetForm = () => { setName('') @@ -399,6 +419,23 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa setMaxCards('') setSelectedDictionaryIDs([]) } + + // Загружаем информацию о закупке, если есть purchase_config_id + if (data.task.purchase_config_id) { + setIsPurchase(true) + if (data.purchase_boards && Array.isArray(data.purchase_boards)) { + setSelectedPurchaseBoards(data.purchase_boards.map(pb => ({ + board_id: pb.board_id, + group_name: pb.group_name || null + }))) + } + // Закупки не могут иметь прогрессию и подзадачи + setProgressionBase('') + setSubtasks([]) + } else { + setIsPurchase(false) + setSelectedPurchaseBoards([]) + } } catch (err) { setError(err.message) } finally { @@ -413,6 +450,13 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa } }, [isTest]) + // Очистка подзадач при переключении задачи в режим закупки + useEffect(() => { + if (isPurchase && subtasks.length > 0) { + setSubtasks([]) + } + }, [isPurchase]) + // Пересчет rewards при изменении reward_message (debounce) useEffect(() => { if (debounceTimer.current) { @@ -597,6 +641,13 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa } } + // Валидация закупки + if (isPurchase && selectedPurchaseBoards.length === 0) { + setError('Выберите хотя бы одну доску или группу для закупки') + setLoading(false) + return + } + // Проверяем, что задача с привязанным желанием не может быть периодической const isLinkedToWishlist = wishlistInfo !== null || (taskId && currentWishlistId) if (isLinkedToWishlist && repetitionPeriodValue && repetitionPeriodValue.trim() !== '') { @@ -711,7 +762,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa value: parseFloat(r.value) || 0, use_progression: !!(progressionBase && r.use_progression) })), - subtasks: isTest ? [] : subtasks.map((st, index) => ({ + subtasks: (isTest || isPurchase) ? [] : subtasks.map((st, index) => ({ id: st.id || undefined, name: st.name.trim() || null, reward_message: st.reward_message.trim() || null, @@ -727,7 +778,10 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa is_test: isTest, words_count: isTest ? parseInt(wordsCount, 10) : undefined, max_cards: isTest && maxCards ? parseInt(maxCards, 10) : undefined, - dictionary_ids: isTest ? selectedDictionaryIDs : undefined + dictionary_ids: isTest ? selectedDictionaryIDs : undefined, + // Purchase-specific fields + is_purchase: isPurchase, + purchase_boards: isPurchase ? selectedPurchaseBoards : undefined } const url = taskId ? `${API_URL}/${taskId}` : API_URL @@ -974,6 +1028,68 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa
)} + {/* Purchase-specific fields */} + {isPurchase && ( +
+ +
+ +
+ {availableBoards.map(board => ( +
+ + {board.groups.length > 0 && !selectedPurchaseBoards.some(pb => pb.board_id === board.id && pb.group_name === null) && ( +
+ {board.groups.map(group => ( + + ))} +
+ )} +
+ ))} + {availableBoards.length === 0 && ( +
+ Нет доступных досок. Создайте доску в разделе "Товары". +
+ )} +
+
+
+ )} + {!wishlistInfo && (
@@ -1123,7 +1239,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa )}
- {!isTest && ( + {!isTest && !isPurchase && (
diff --git a/play-life-web/src/components/TaskList.css b/play-life-web/src/components/TaskList.css index a1187bc..f98bab8 100644 --- a/play-life-web/src/components/TaskList.css +++ b/play-life-web/src/components/TaskList.css @@ -917,3 +917,13 @@ transform: translateY(-1px); box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3); } + +.task-add-modal-button-purchase { + background: linear-gradient(to right, #27ae60, #229954); + color: white; +} + +.task-add-modal-button-purchase:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(39, 174, 96, 0.3); +} diff --git a/play-life-web/src/components/TaskList.jsx b/play-life-web/src/components/TaskList.jsx index 5ad4988..0a86b4a 100644 --- a/play-life-web/src/components/TaskList.jsx +++ b/play-life-web/src/components/TaskList.jsx @@ -87,6 +87,17 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry return } + // Для задач-закупок открываем экран закупок + const isPurchase = task.purchase_config_id != null + if (isPurchase) { + onNavigate?.('purchase', { + purchaseConfigId: task.purchase_config_id, + taskId: task.id, + taskName: task.name + }) + return + } + // Для обычных задач открываем диалог подтверждения setSelectedTaskForDetail(task.id) } @@ -408,7 +419,19 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry } defaultDate.setHours(0, 0, 0, 0) - setPostponeDate(formatDateToLocal(defaultDate)) + const plannedStr = formatDateToLocal(defaultDate) + // Предвыбираем дату только если она не совпадает с текущей next_show_at (т.е. если чипс "По плану" будет показан) + let nextShowStr = null + if (task.next_show_at) { + const d = new Date(task.next_show_at) + d.setHours(0, 0, 0, 0) + nextShowStr = formatDateToLocal(d) + } + if (plannedStr !== nextShowStr) { + setPostponeDate(plannedStr) + } else { + setPostponeDate('') + } } const handlePostponeSubmit = async () => { @@ -791,7 +814,8 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry const hasProgression = task.has_progression || task.progression_base != null const hasSubtasks = task.subtasks_count > 0 const isTest = task.config_id != null - const showDetailOnCheckmark = !isTest + const isPurchase = task.purchase_config_id != null + const showDetailOnCheckmark = !isTest && !isPurchase const isWishlist = task.wishlist_id != null // Проверяем бесконечную задачу: repetition_period = 0 И (repetition_date = 0 ИЛИ отсутствует) @@ -816,7 +840,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, error, onRetry
handleCheckmarkClick(task, e)} - title={isTest ? 'Запустить тест' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу')} + title={isTest ? 'Запустить тест' : (isPurchase ? 'Открыть закупки' : (showDetailOnCheckmark ? 'Открыть детали' : 'Выполнить задачу'))} > {isTest ? ( + ) : isPurchase ? ( + + + + ) : isWishlist ? ( <>