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}/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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
"version": "6.7.0",
|
||||
"version": "6.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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() {
|
||||
</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 && (
|
||||
<div className={getTabContainerClasses('profile')}>
|
||||
<div className={getInnerContainerClasses('profile')}>
|
||||
|
||||
@@ -127,10 +127,10 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
|
||||
|
||||
{!loading && !error && item && (
|
||||
<>
|
||||
{item.description && (
|
||||
<div className="shopping-item-description-card">
|
||||
<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)) {
|
||||
let host
|
||||
try {
|
||||
@@ -145,12 +145,18 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
|
||||
)
|
||||
}
|
||||
return <span key={i}>{part}</span>
|
||||
})}
|
||||
})
|
||||
) : (
|
||||
<span style={{ color: '#9ca3af' }}>Описание отсутствует</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shopping-item-history-button"
|
||||
onClick={() => {}}
|
||||
onClick={() => {
|
||||
onClose?.(true)
|
||||
onNavigate?.('shopping-item-history', { itemId: itemId })
|
||||
}}
|
||||
title="История"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="shopping-item-complete-row">
|
||||
<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