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:
@@ -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) => {
|
||||
<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">
|
||||
|
||||
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