6.8.0: История покупок товаров
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m20s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4584,6 +4584,8 @@ func main() {
|
|||||||
protected.HandleFunc("/api/shopping/items/{id}", app.deleteShoppingItemHandler).Methods("DELETE", "OPTIONS")
|
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}/complete", app.completeShoppingItemHandler).Methods("POST", "OPTIONS")
|
||||||
protected.HandleFunc("/api/shopping/items/{id}/postpone", app.postponeShoppingItemHandler).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/groups", app.getShoppingGroupSuggestionsHandler).Methods("GET", "OPTIONS")
|
||||||
protected.HandleFunc("/api/shopping/invite/{token}", app.getShoppingBoardInviteInfoHandler).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")
|
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 boardID int
|
||||||
var volumeBase float64
|
var volumeBase float64
|
||||||
var repetitionPeriod sql.NullString
|
var repetitionPeriod sql.NullString
|
||||||
|
var itemName string
|
||||||
err = a.DB.QueryRow(`
|
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
|
FROM shopping_items WHERE id = $1 AND deleted = FALSE
|
||||||
`, itemID).Scan(&boardID, &volumeBase, &repetitionPeriod)
|
`, itemID).Scan(&boardID, &volumeBase, &repetitionPeriod, &itemName)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
sendErrorWithCORS(w, "Item not found", http.StatusNotFound)
|
sendErrorWithCORS(w, "Item not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
@@ -18956,6 +18959,15 @@ func (a *App) completeShoppingItemHandler(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -19089,3 +19101,158 @@ func (a *App) getShoppingGroupSuggestionsHandler(w http.ResponseWriter, r *http.
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(groups)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS shopping_item_history;
|
||||||
@@ -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);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "6.7.0",
|
"version": "6.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import ShoppingList from './components/ShoppingList'
|
|||||||
import ShoppingItemForm from './components/ShoppingItemForm'
|
import ShoppingItemForm from './components/ShoppingItemForm'
|
||||||
import ShoppingBoardForm from './components/ShoppingBoardForm'
|
import ShoppingBoardForm from './components/ShoppingBoardForm'
|
||||||
import ShoppingBoardJoinPreview from './components/ShoppingBoardJoinPreview'
|
import ShoppingBoardJoinPreview from './components/ShoppingBoardJoinPreview'
|
||||||
|
import ShoppingItemHistory from './components/ShoppingItemHistory'
|
||||||
import TodoistIntegration from './components/TodoistIntegration'
|
import TodoistIntegration from './components/TodoistIntegration'
|
||||||
import TelegramIntegration from './components/TelegramIntegration'
|
import TelegramIntegration from './components/TelegramIntegration'
|
||||||
import FitbitIntegration from './components/FitbitIntegration'
|
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 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-item-form': false,
|
||||||
'shopping-board-form': false,
|
'shopping-board-form': false,
|
||||||
'shopping-board-join': false,
|
'shopping-board-join': false,
|
||||||
|
'shopping-item-history': false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
|
||||||
@@ -114,6 +116,7 @@ function AppContent() {
|
|||||||
'shopping-item-form': false,
|
'shopping-item-form': false,
|
||||||
'shopping-board-form': false,
|
'shopping-board-form': false,
|
||||||
'shopping-board-join': false,
|
'shopping-board-join': false,
|
||||||
|
'shopping-item-history': false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Параметры для навигации между вкладками
|
// Параметры для навигации между вкладками
|
||||||
@@ -292,7 +295,7 @@ function AppContent() {
|
|||||||
|
|
||||||
// Проверяем URL только для глубоких табов
|
// Проверяем URL только для глубоких табов
|
||||||
const tabFromUrl = urlParams.get('tab')
|
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) {
|
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl) && window.history.length > 1) {
|
||||||
// Восстанавливаем глубокий таб из URL только если есть история (не рестарт PWA)
|
// Восстанавливаем глубокий таб из URL только если есть история (не рестарт PWA)
|
||||||
@@ -789,7 +792,7 @@ function AppContent() {
|
|||||||
return
|
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 текущей записи истории (куда мы вернулись)
|
// Проверяем state текущей записи истории (куда мы вернулись)
|
||||||
if (event.state && event.state.tab) {
|
if (event.state && event.state.tab) {
|
||||||
@@ -1113,7 +1116,7 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
|
// Определяем, нужно ли скрывать нижнюю панель (для 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') {
|
if (tabName === 'current') {
|
||||||
return 'max-w-7xl mx-auto p-4 md:p-6'
|
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'
|
return 'max-w-7xl mx-auto px-4 md:px-8 py-0'
|
||||||
}
|
}
|
||||||
// Fullscreen табы без отступов
|
// Fullscreen табы без отступов
|
||||||
@@ -1404,6 +1407,18 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['shopping-item-history'] && (
|
||||||
|
<div className={getTabContainerClasses('shopping-item-history')}>
|
||||||
|
<div className={getInnerContainerClasses('shopping-item-history')}>
|
||||||
|
<ShoppingItemHistory
|
||||||
|
key={tabParams.itemId}
|
||||||
|
itemId={tabParams.itemId}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loadedTabs.profile && (
|
{loadedTabs.profile && (
|
||||||
<div className={getTabContainerClasses('profile')}>
|
<div className={getTabContainerClasses('profile')}>
|
||||||
<div className={getInnerContainerClasses('profile')}>
|
<div className={getInnerContainerClasses('profile')}>
|
||||||
|
|||||||
@@ -127,10 +127,10 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
|
|||||||
|
|
||||||
{!loading && !error && item && (
|
{!loading && !error && item && (
|
||||||
<>
|
<>
|
||||||
{item.description && (
|
|
||||||
<div className="shopping-item-description-card">
|
<div className="shopping-item-description-card">
|
||||||
<div className="shopping-item-description">
|
<div className="shopping-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)) {
|
if (/^https?:\/\//i.test(part)) {
|
||||||
let host
|
let host
|
||||||
try {
|
try {
|
||||||
@@ -145,12 +145,18 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <span key={i}>{part}</span>
|
return <span key={i}>{part}</span>
|
||||||
})}
|
})
|
||||||
|
) : (
|
||||||
|
<span style={{ color: '#9ca3af' }}>Описание отсутствует</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shopping-item-history-button"
|
className="shopping-item-history-button"
|
||||||
onClick={() => {}}
|
onClick={() => {
|
||||||
|
onClose?.(true)
|
||||||
|
onNavigate?.('shopping-item-history', { itemId: itemId })
|
||||||
|
}}
|
||||||
title="История"
|
title="История"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@@ -162,7 +168,6 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="shopping-item-complete-row">
|
<div className="shopping-item-complete-row">
|
||||||
<div className="progression-input-wrapper">
|
<div className="progression-input-wrapper">
|
||||||
|
|||||||
180
play-life-web/src/components/ShoppingItemHistory.jsx
Normal file
180
play-life-web/src/components/ShoppingItemHistory.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{onNavigate && (
|
||||||
|
<button
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
className="close-x-button"
|
||||||
|
title="Закрыть"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="fixed inset-0 flex justify-center items-center">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
|
||||||
|
<div className="text-gray-600 font-medium">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<LoadingError onRetry={fetchHistory} />
|
||||||
|
) : history.length === 0 ? (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-800 mb-6" style={{ marginTop: '1.25rem' }}>История покупок</h2>
|
||||||
|
<div className="flex justify-center items-center py-16">
|
||||||
|
<div className="text-gray-500 text-lg">История пуста</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-800 mb-6" style={{ marginTop: '1.25rem' }}>История покупок</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{history.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="bg-white rounded-lg p-4 shadow-sm border border-gray-200 relative"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(entry.id)}
|
||||||
|
disabled={deletingId === entry.id}
|
||||||
|
className="absolute top-4 right-4"
|
||||||
|
style={{
|
||||||
|
color: '#6b7280',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0.25rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
opacity: deletingId === entry.id ? 0.5 : 1,
|
||||||
|
zIndex: 10
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (deletingId !== entry.id) {
|
||||||
|
e.currentTarget.style.backgroundColor = '#f3f4f6'
|
||||||
|
e.currentTarget.style.color = '#1f2937'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent'
|
||||||
|
e.currentTarget.style.color = '#6b7280'
|
||||||
|
}}
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
{deletingId === entry.id ? (
|
||||||
|
<svg className="w-5 h-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 6h18"></path>
|
||||||
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="text-gray-800 pr-8">
|
||||||
|
{entry.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-800 font-semibold mt-1">
|
||||||
|
Объём: {formatVolume(entry.volume)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-2">
|
||||||
|
{formatDate(entry.completed_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{toastMessage && (
|
||||||
|
<Toast
|
||||||
|
message={toastMessage.text}
|
||||||
|
type={toastMessage.type}
|
||||||
|
onClose={() => setToastMessage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShoppingItemHistory
|
||||||
Reference in New Issue
Block a user