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

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