6.8.0: История покупок товаров
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:
poignatov
2026-03-10 17:05:59 +03:00
parent e962f49407
commit 3cac8d0452
8 changed files with 408 additions and 30 deletions

View File

@@ -1 +1 @@
6.7.0
6.8.0

View File

@@ -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,
})
}

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS shopping_item_history;

View File

@@ -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);

View File

@@ -1,6 +1,6 @@
{
"name": "play-life-web",
"version": "6.7.0",
"version": "6.8.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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')}>

View File

@@ -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) => {
<div className="shopping-item-description-card">
<div className="shopping-item-description">
{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 <span key={i}>{part}</span>
})}
</div>
<button
type="button"
className="shopping-item-history-button"
onClick={() => {}}
title="История"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
</button>
})
) : (
<span style={{ color: '#9ca3af' }}>Описание отсутствует</span>
)}
</div>
)}
<button
type="button"
className="shopping-item-history-button"
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">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
</button>
</div>
<div className="shopping-item-complete-row">
<div className="progression-input-wrapper">

View 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