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 && (
Ошибка загрузки
+ +Нет товаров
+