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'] && ( +
+
+ +
+
+ )} + {loadedTabs.profile && (
diff --git a/play-life-web/src/components/ShoppingItemDetail.jsx b/play-life-web/src/components/ShoppingItemDetail.jsx index a7b53b4..cde251e 100644 --- a/play-life-web/src/components/ShoppingItemDetail.jsx +++ b/play-life-web/src/components/ShoppingItemDetail.jsx @@ -127,10 +127,10 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav {!loading && !error && item && ( <> - {item.description && ( -
-
- {item.description.split(/(https?:\/\/[^\s<>"'`,;!)\]]+)/gi).map((part, i) => { +
+
+ {item.description ? ( + item.description.split(/(https?:\/\/[^\s<>"'`,;!)\]]+)/gi).map((part, i) => { if (/^https?:\/\//i.test(part)) { let host try { @@ -145,24 +145,29 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav ) } return {part} - })} -
- + }) + ) : ( + Описание отсутствует + )}
- )} + +
diff --git a/play-life-web/src/components/ShoppingItemHistory.jsx b/play-life-web/src/components/ShoppingItemHistory.jsx new file mode 100644 index 0000000..49be2e2 --- /dev/null +++ b/play-life-web/src/components/ShoppingItemHistory.jsx @@ -0,0 +1,180 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { useAuth } from './auth/AuthContext' +import LoadingError from './LoadingError' +import Toast from './Toast' +import './Integrations.css' + +function ShoppingItemHistory({ itemId, onNavigate }) { + const { authFetch } = useAuth() + const [history, setHistory] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [toastMessage, setToastMessage] = useState(null) + const [deletingId, setDeletingId] = useState(null) + + const fetchHistory = useCallback(async () => { + if (!itemId) return + try { + setLoading(true) + setError(null) + const response = await authFetch(`/api/shopping/items/${itemId}/history`) + if (!response.ok) { + throw new Error('Ошибка загрузки истории') + } + const data = await response.json() + setHistory(Array.isArray(data) ? data : []) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + }, [itemId, authFetch]) + + useEffect(() => { + fetchHistory() + }, [fetchHistory]) + + const handleDelete = async (historyId) => { + setDeletingId(historyId) + try { + const response = await authFetch(`/api/shopping/history/${historyId}`, { + method: 'DELETE', + }) + if (!response.ok) { + throw new Error('Ошибка удаления') + } + setHistory(prev => prev.filter(entry => entry.id !== historyId)) + setToastMessage({ text: 'Запись удалена', type: 'success' }) + } catch (err) { + setToastMessage({ text: err.message || 'Ошибка', type: 'error' }) + } finally { + setDeletingId(null) + } + } + + const formatDate = (dateStr) => { + const date = new Date(dateStr) + const day = date.getDate() + const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'] + const month = months[date.getMonth()] + const year = date.getFullYear() + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${day} ${month} ${year}, ${hours}:${minutes}` + } + + const formatVolume = (volume) => { + if (volume === 1) return '1' + const rounded = Math.round(volume * 10000) / 10000 + return rounded.toString() + } + + return ( +
+ {onNavigate && ( + + )} + + {loading ? ( +
+
+
+
Загрузка...
+
+
+ ) : error ? ( + + ) : history.length === 0 ? ( + <> +

История покупок

+
+
История пуста
+
+ + ) : ( +
+

История покупок

+
+ {history.map((entry) => ( +
+ +
+ {entry.name} +
+
+ Объём: {formatVolume(entry.volume)} +
+
+ {formatDate(entry.completed_at)} +
+
+ ))} +
+
+ )} + + {toastMessage && ( + setToastMessage(null)} + /> + )} +
+ ) +} + +export default ShoppingItemHistory