diff --git a/VERSION b/VERSION index f0e13c5..e029aa9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.7.0 +6.8.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 25d643b..aa417c4 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -4584,6 +4584,8 @@ func main() { protected.HandleFunc("/api/shopping/items/{id}", app.deleteShoppingItemHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/shopping/items/{id}/complete", app.completeShoppingItemHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/shopping/items/{id}/postpone", app.postponeShoppingItemHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/shopping/items/{id}/history", app.getShoppingItemHistoryHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/shopping/history/{id}", app.deleteShoppingItemHistoryHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/shopping/groups", app.getShoppingGroupSuggestionsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/shopping/invite/{token}", app.getShoppingBoardInviteInfoHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/shopping/invite/{token}/join", app.joinShoppingBoardHandler).Methods("POST", "OPTIONS") @@ -18885,10 +18887,11 @@ func (a *App) completeShoppingItemHandler(w http.ResponseWriter, r *http.Request var boardID int var volumeBase float64 var repetitionPeriod sql.NullString + var itemName string err = a.DB.QueryRow(` - SELECT board_id, volume_base, repetition_period::text + SELECT board_id, volume_base, repetition_period::text, name FROM shopping_items WHERE id = $1 AND deleted = FALSE - `, itemID).Scan(&boardID, &volumeBase, &repetitionPeriod) + `, itemID).Scan(&boardID, &volumeBase, &repetitionPeriod, &itemName) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Item not found", http.StatusNotFound) return @@ -18956,6 +18959,15 @@ func (a *App) completeShoppingItemHandler(w http.ResponseWriter, r *http.Request return } + // Записываем в историю покупок + _, histErr := a.DB.Exec(` + INSERT INTO shopping_item_history (item_id, user_id, name, volume, completed_at) + VALUES ($1, $2, $3, $4, $5) + `, itemID, userID, itemName, actualVolume, now) + if histErr != nil { + log.Printf("Error inserting shopping item history: %v", histErr) + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, @@ -19089,3 +19101,158 @@ func (a *App) getShoppingGroupSuggestionsHandler(w http.ResponseWriter, r *http. w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(groups) } + +// getShoppingItemHistoryHandler возвращает последние 10 покупок товара +func (a *App) getShoppingItemHistoryHandler(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) + itemID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) + return + } + + // Получаем board_id товара для проверки доступа + var boardID int + err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1`, itemID).Scan(&boardID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Item not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting shopping item for history: %v", err) + sendErrorWithCORS(w, "Error getting history", http.StatusInternalServerError) + return + } + + // Проверяем доступ + var ownerID int + a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) + if ownerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + rows, err := a.DB.Query(` + SELECT id, name, volume, completed_at + FROM shopping_item_history + WHERE item_id = $1 + ORDER BY completed_at DESC + LIMIT 10 + `, itemID) + if err != nil { + log.Printf("Error getting shopping item history: %v", err) + sendErrorWithCORS(w, "Error getting history", http.StatusInternalServerError) + return + } + defer rows.Close() + + type HistoryEntry struct { + ID int `json:"id"` + Name string `json:"name"` + Volume float64 `json:"volume"` + CompletedAt string `json:"completed_at"` + } + + history := []HistoryEntry{} + for rows.Next() { + var entry HistoryEntry + var completedAt time.Time + if err := rows.Scan(&entry.ID, &entry.Name, &entry.Volume, &completedAt); err != nil { + log.Printf("Error scanning shopping item history: %v", err) + continue + } + entry.CompletedAt = completedAt.Format(time.RFC3339) + history = append(history, entry) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(history) +} + +// deleteShoppingItemHistoryHandler удаляет запись из истории покупок +func (a *App) deleteShoppingItemHistoryHandler(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) + historyID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid history ID", http.StatusBadRequest) + return + } + + // Получаем item_id из записи истории для проверки доступа + var itemID int + err = a.DB.QueryRow(`SELECT item_id FROM shopping_item_history WHERE id = $1`, historyID).Scan(&itemID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "History entry not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting history entry: %v", err) + sendErrorWithCORS(w, "Error deleting history entry", http.StatusInternalServerError) + return + } + + // Получаем board_id товара для проверки доступа + var boardID int + err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1`, itemID).Scan(&boardID) + if err != nil { + log.Printf("Error getting shopping item for history delete: %v", err) + sendErrorWithCORS(w, "Error deleting history entry", http.StatusInternalServerError) + return + } + + // Проверяем доступ + var ownerID int + a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) + if ownerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + _, err = a.DB.Exec(`DELETE FROM shopping_item_history WHERE id = $1`, historyID) + if err != nil { + log.Printf("Error deleting shopping item history: %v", err) + sendErrorWithCORS(w, "Error deleting history entry", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + }) +} diff --git a/play-life-backend/migrations/000028_shopping_item_history.down.sql b/play-life-backend/migrations/000028_shopping_item_history.down.sql new file mode 100644 index 0000000..cee1bd5 --- /dev/null +++ b/play-life-backend/migrations/000028_shopping_item_history.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS shopping_item_history; diff --git a/play-life-backend/migrations/000028_shopping_item_history.up.sql b/play-life-backend/migrations/000028_shopping_item_history.up.sql new file mode 100644 index 0000000..452597b --- /dev/null +++ b/play-life-backend/migrations/000028_shopping_item_history.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE shopping_item_history ( + id SERIAL PRIMARY KEY, + item_id INTEGER NOT NULL REFERENCES shopping_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + name VARCHAR(255) NOT NULL, + volume NUMERIC(10,4) NOT NULL DEFAULT 1, + completed_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_shopping_item_history_item_id ON shopping_item_history(item_id); diff --git a/play-life-web/package.json b/play-life-web/package.json index a88e751..df01704 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "6.7.0", + "version": "6.8.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index f6d0409..46c8b24 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -18,6 +18,7 @@ import ShoppingList from './components/ShoppingList' import ShoppingItemForm from './components/ShoppingItemForm' import ShoppingBoardForm from './components/ShoppingBoardForm' import ShoppingBoardJoinPreview from './components/ShoppingBoardJoinPreview' +import ShoppingItemHistory from './components/ShoppingItemHistory' import TodoistIntegration from './components/TodoistIntegration' import TelegramIntegration from './components/TelegramIntegration' import FitbitIntegration from './components/FitbitIntegration' @@ -34,7 +35,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'] +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'] /** * Гарантирует базовую запись истории для главного экрана перед глубоким табом. @@ -85,6 +86,7 @@ function AppContent() { 'shopping-item-form': false, 'shopping-board-form': false, 'shopping-board-join': false, + 'shopping-item-history': false, }) // Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок) @@ -114,6 +116,7 @@ function AppContent() { 'shopping-item-form': false, 'shopping-board-form': false, 'shopping-board-join': false, + 'shopping-item-history': false, }) // Параметры для навигации между вкладками @@ -292,7 +295,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'] + 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'] if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl) && window.history.length > 1) { // Восстанавливаем глубокий таб из URL только если есть история (не рестарт PWA) @@ -789,7 +792,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'] + 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'] // Проверяем state текущей записи истории (куда мы вернулись) if (event.state && event.state.tab) { @@ -1113,7 +1116,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' + 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' // Функция для получения классов скролл-контейнера для каждого таба // Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла @@ -1142,7 +1145,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') { + if (tabName === 'full' || tabName === 'priorities' || tabName === 'dictionaries' || tabName === 'words' || tabName === 'shopping-item-history') { return 'max-w-7xl mx-auto px-4 md:px-8 py-0' } // Fullscreen табы без отступов @@ -1404,6 +1407,18 @@ function AppContent() { )} + {loadedTabs['shopping-item-history'] && ( +