6.22.0: Авторасчёт сроков товаров по истории
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
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.21.3",
|
||||
"version": "6.22.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DayPicker } from 'react-day-picker'
|
||||
import { ru } from 'react-day-picker/locale'
|
||||
import 'react-day-picker/style.css'
|
||||
import './TaskList.css'
|
||||
import './TaskDetail.css'
|
||||
import './ShoppingList.css'
|
||||
|
||||
// Форматирование даты в YYYY-MM-DD (локальное время)
|
||||
@@ -59,6 +60,7 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
|
||||
const [selectedItemForDetail, setSelectedItemForDetail] = useState(null)
|
||||
const [selectedItemForPostpone, setSelectedItemForPostpone] = useState(null)
|
||||
const [postponeDate, setPostponeDate] = useState('')
|
||||
const [postponeRemaining, setPostponeRemaining] = useState('')
|
||||
const [isPostponing, setIsPostponing] = useState(false)
|
||||
const [toast, setToast] = useState(null)
|
||||
const [expandedFuture, setExpandedFuture] = useState({})
|
||||
@@ -153,6 +155,7 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
|
||||
if (currentPostpone) {
|
||||
setSelectedItemForPostpone(null)
|
||||
setPostponeDate('')
|
||||
setPostponeRemaining('')
|
||||
historyPushedForPostponeRef.current = false
|
||||
return
|
||||
}
|
||||
@@ -246,6 +249,7 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
|
||||
} else {
|
||||
setSelectedItemForPostpone(null)
|
||||
setPostponeDate('')
|
||||
setPostponeRemaining('')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,10 +258,14 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
|
||||
setIsPostponing(true)
|
||||
try {
|
||||
const nextShowAt = new Date(dateStr + 'T00:00:00')
|
||||
const payload = { next_show_at: nextShowAt.toISOString() }
|
||||
if (postponeRemaining.trim()) {
|
||||
payload.volume_remaining = parseFloat(postponeRemaining)
|
||||
}
|
||||
const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ next_show_at: nextShowAt.toISOString() })
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (res.ok) {
|
||||
setToast({ message: 'Дата обновлена', type: 'success' })
|
||||
@@ -299,10 +307,14 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
|
||||
if (!selectedItemForPostpone) return
|
||||
setIsPostponing(true)
|
||||
try {
|
||||
const payload = { next_show_at: null }
|
||||
if (postponeRemaining.trim()) {
|
||||
payload.volume_remaining = parseFloat(postponeRemaining)
|
||||
}
|
||||
const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ next_show_at: null })
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (res.ok) {
|
||||
setToast({ message: 'Дата убрана', type: 'success' })
|
||||
@@ -316,6 +328,28 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Автообновление даты в календаре при вводе остатка
|
||||
useEffect(() => {
|
||||
if (!selectedItemForPostpone) return
|
||||
if (selectedItemForPostpone.daily_consumption > 0) {
|
||||
const remaining = postponeRemaining.trim() ? parseFloat(postponeRemaining) : (selectedItemForPostpone.estimated_remaining ?? 0)
|
||||
if (!isNaN(remaining) && remaining >= 0) {
|
||||
const daily = selectedItemForPostpone.daily_consumption
|
||||
const daysLeft = remaining / daily
|
||||
const target = new Date()
|
||||
target.setHours(0, 0, 0, 0)
|
||||
target.setDate(target.getDate() + Math.ceil(daysLeft))
|
||||
setPostponeDate(formatDateToLocal(target))
|
||||
return
|
||||
}
|
||||
}
|
||||
// Фолбэк: завтра
|
||||
const tomorrow = new Date()
|
||||
tomorrow.setHours(0, 0, 0, 0)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
setPostponeDate(formatDateToLocal(tomorrow))
|
||||
}, [postponeRemaining, selectedItemForPostpone])
|
||||
|
||||
const renderItem = (item) => {
|
||||
let dateDisplay = null
|
||||
if (item.next_show_at) {
|
||||
@@ -350,7 +384,14 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
|
||||
</div>
|
||||
<div className="task-name-container">
|
||||
<div className="task-name-wrapper">
|
||||
<div className="task-name">{item.name}</div>
|
||||
<div className="task-name">
|
||||
{item.name}
|
||||
{item.estimated_remaining > 0 && (
|
||||
<span style={{ color: '#9ca3af', fontSize: '0.8em', marginLeft: '6px' }}>
|
||||
~{Math.round(item.estimated_remaining * 10) / 10}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{dateDisplay && (
|
||||
<div className="task-next-show-date">{dateDisplay}</div>
|
||||
)}
|
||||
@@ -362,28 +403,8 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSelectedItemForPostpone(item)
|
||||
// Предвыбираем дату "по плану" если она не совпадает с текущей next_show_at
|
||||
const now2 = new Date()
|
||||
now2.setHours(0, 0, 0, 0)
|
||||
let planned
|
||||
if (item.repetition_period) {
|
||||
planned = calculateNextDateFromRepetitionPeriod(item.repetition_period)
|
||||
}
|
||||
if (!planned) {
|
||||
planned = new Date(now2)
|
||||
planned.setDate(planned.getDate() + 1)
|
||||
}
|
||||
planned.setHours(0, 0, 0, 0)
|
||||
const plannedStr = formatDateToLocal(planned)
|
||||
let nextShowStr = null
|
||||
if (item.next_show_at) {
|
||||
nextShowStr = formatDateToLocal(new Date(item.next_show_at))
|
||||
}
|
||||
if (plannedStr !== nextShowStr) {
|
||||
setPostponeDate(plannedStr)
|
||||
} else {
|
||||
setPostponeDate('')
|
||||
}
|
||||
setPostponeRemaining(item.estimated_remaining != null && item.estimated_remaining > 0 ? (Math.round(item.estimated_remaining * 10) / 10).toString() : '')
|
||||
// Дата рассчитывается в useEffect по остатку
|
||||
}}
|
||||
title="Перенести"
|
||||
>
|
||||
@@ -565,6 +586,41 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="task-postpone-modal-content">
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<label className="progression-label" style={{ marginBottom: '0.25rem' }}>Остаток</label>
|
||||
<div className="progression-input-wrapper">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={postponeRemaining}
|
||||
onChange={(e) => setPostponeRemaining(e.target.value)}
|
||||
placeholder={selectedItemForPostpone.estimated_remaining != null ? (Math.round(selectedItemForPostpone.estimated_remaining * 10) / 10).toString() : '0'}
|
||||
className="progression-input"
|
||||
style={{ paddingRight: postponeRemaining ? '2rem' : '0.75rem' }}
|
||||
/>
|
||||
{postponeRemaining && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPostponeRemaining('')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '8px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#9ca3af',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.1rem',
|
||||
padding: '4px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-postpone-calendar">
|
||||
<DayPicker
|
||||
mode="single"
|
||||
@@ -598,13 +654,21 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
|
||||
Завтра
|
||||
</button>
|
||||
)}
|
||||
{!isCurrentDatePlanned && (
|
||||
{selectedItemForPostpone.daily_consumption > 0 && (
|
||||
<button
|
||||
className="task-postpone-quick-button"
|
||||
onClick={() => handlePostponeSubmitWithDate(plannedDateStr)}
|
||||
onClick={() => {
|
||||
const remaining = postponeRemaining.trim() ? parseFloat(postponeRemaining) : (selectedItemForPostpone.estimated_remaining ?? 0)
|
||||
const daily = selectedItemForPostpone.daily_consumption
|
||||
const daysLeft = remaining / daily
|
||||
const target = new Date()
|
||||
target.setHours(0, 0, 0, 0)
|
||||
target.setDate(target.getDate() + Math.ceil(daysLeft))
|
||||
handlePostponeSubmitWithDate(formatDateToLocal(target))
|
||||
}}
|
||||
disabled={isPostponing}
|
||||
>
|
||||
По плану
|
||||
По остатку
|
||||
</button>
|
||||
)}
|
||||
{nextShowAtStr && (
|
||||
|
||||
@@ -10,7 +10,8 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
|
||||
const [item, setItem] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [volumeValue, setVolumeValue] = useState('')
|
||||
const [volumeRemaining, setVolumeRemaining] = useState('')
|
||||
const [volumePurchased, setVolumePurchased] = useState('')
|
||||
const [isCompleting, setIsCompleting] = useState(false)
|
||||
const [toastMessage, setToastMessage] = useState(null)
|
||||
|
||||
@@ -38,7 +39,8 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
|
||||
setItem(null)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setVolumeValue('')
|
||||
setVolumeRemaining('')
|
||||
setVolumePurchased('')
|
||||
}
|
||||
}, [itemId, fetchItem])
|
||||
|
||||
@@ -48,13 +50,21 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
|
||||
setIsCompleting(true)
|
||||
try {
|
||||
const payload = {}
|
||||
if (volumeValue.trim()) {
|
||||
payload.volume = parseFloat(volumeValue)
|
||||
if (isNaN(payload.volume)) {
|
||||
throw new Error('Неверное значение объёма')
|
||||
if (volumeRemaining.trim()) {
|
||||
payload.volume_remaining = parseFloat(volumeRemaining)
|
||||
if (isNaN(payload.volume_remaining)) {
|
||||
throw new Error('Неверное значение остатка')
|
||||
}
|
||||
} else {
|
||||
payload.volume = item.last_volume ?? item.volume_base
|
||||
payload.volume_remaining = item.estimated_remaining ?? 0
|
||||
}
|
||||
if (volumePurchased.trim()) {
|
||||
payload.volume_purchased = parseFloat(volumePurchased)
|
||||
if (isNaN(payload.volume_purchased)) {
|
||||
throw new Error('Неверное значение докупки')
|
||||
}
|
||||
} else {
|
||||
payload.volume_purchased = item.median_purchased ?? item.volume_base ?? 1
|
||||
}
|
||||
|
||||
const response = await authFetch(`/api/shopping/items/${itemId}/complete`, {
|
||||
@@ -169,42 +179,78 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shopping-item-complete-row">
|
||||
<label className="progression-label">Объём</label>
|
||||
<div className="progression-input-wrapper">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={volumeValue}
|
||||
onChange={(e) => setVolumeValue(e.target.value)}
|
||||
placeholder={(item.last_volume ?? item.volume_base)?.toString() || '1'}
|
||||
className="progression-input"
|
||||
/>
|
||||
<div className="progression-controls-capsule">
|
||||
<button
|
||||
type="button"
|
||||
className="progression-control-btn progression-control-minus"
|
||||
onClick={() => {
|
||||
const base = item.last_volume ?? item.volume_base ?? 1
|
||||
const current = volumeValue.trim() ? parseFloat(volumeValue) : base
|
||||
const step = item.volume_base || 1
|
||||
setVolumeValue((current - step).toString())
|
||||
}}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="progression-control-btn progression-control-plus"
|
||||
onClick={() => {
|
||||
const base = item.last_volume ?? item.volume_base ?? 1
|
||||
const current = volumeValue.trim() ? parseFloat(volumeValue) : base
|
||||
const step = item.volume_base || 1
|
||||
setVolumeValue((current + step).toString())
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end', marginBottom: '0.75rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label className="progression-label">Остаток</label>
|
||||
<div className="progression-input-wrapper">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={volumeRemaining}
|
||||
onChange={(e) => setVolumeRemaining(e.target.value)}
|
||||
placeholder={item.estimated_remaining != null ? Math.round(item.estimated_remaining * 10) / 10 + '' : '0'}
|
||||
className="progression-input"
|
||||
/>
|
||||
{volumeRemaining && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVolumeRemaining('')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '8px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#9ca3af',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.1rem',
|
||||
padding: '4px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label className="progression-label">Докуплено</label>
|
||||
<div className="progression-input-wrapper">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={volumePurchased}
|
||||
onChange={(e) => setVolumePurchased(e.target.value)}
|
||||
placeholder={(item.median_purchased ?? item.volume_base ?? 1).toString()}
|
||||
className="progression-input"
|
||||
/>
|
||||
<div className="progression-controls-capsule">
|
||||
<button
|
||||
type="button"
|
||||
className="progression-control-btn progression-control-minus"
|
||||
onClick={() => {
|
||||
const base = item.median_purchased ?? item.volume_base ?? 1
|
||||
const current = volumePurchased.trim() ? parseFloat(volumePurchased) : base
|
||||
const step = item.volume_base || 1
|
||||
setVolumePurchased(Math.max(0, current - step).toString())
|
||||
}}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="progression-control-btn progression-control-plus"
|
||||
onClick={() => {
|
||||
const base = item.median_purchased ?? item.volume_base ?? 1
|
||||
const current = volumePurchased.trim() ? parseFloat(volumePurchased) : base
|
||||
const step = item.volume_base || 1
|
||||
setVolumePurchased((current + step).toString())
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,6 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
|
||||
const [groupName, setGroupName] = useState('')
|
||||
const [groupSuggestions, setGroupSuggestions] = useState([])
|
||||
const [volumeBase, setVolumeBase] = useState('')
|
||||
const [repetitionPeriodValue, setRepetitionPeriodValue] = useState('')
|
||||
const [repetitionPeriodType, setRepetitionPeriodType] = useState('day')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingItem, setLoadingItem] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
@@ -59,26 +57,6 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
|
||||
if (data.volume_base && data.volume_base !== 1) {
|
||||
setVolumeBase(data.volume_base.toString())
|
||||
}
|
||||
if (data.repetition_period) {
|
||||
const parts = data.repetition_period.trim().split(/\s+/)
|
||||
if (parts.length >= 2) {
|
||||
const value = parseInt(parts[0], 10)
|
||||
const unit = parts[1].toLowerCase()
|
||||
setRepetitionPeriodValue(value.toString())
|
||||
// Map PostgreSQL units to our types
|
||||
if (unit.startsWith('day')) setRepetitionPeriodType('day')
|
||||
else if (unit.startsWith('week') || unit === 'wks' || unit === 'wk') setRepetitionPeriodType('week')
|
||||
else if (unit.startsWith('mon')) setRepetitionPeriodType('month')
|
||||
else if (unit.startsWith('year') || unit === 'yrs' || unit === 'yr') setRepetitionPeriodType('year')
|
||||
else if (unit.startsWith('hour') || unit === 'hrs' || unit === 'hr') setRepetitionPeriodType('hour')
|
||||
else if (unit.startsWith('min')) setRepetitionPeriodType('minute')
|
||||
// Handle PostgreSQL weeks-as-days: "7 days" -> 1 week
|
||||
if (unit.startsWith('day') && value % 7 === 0 && value >= 7) {
|
||||
setRepetitionPeriodValue((value / 7).toString())
|
||||
setRepetitionPeriodType('week')
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setToastMessage({ text: 'Ошибка загрузки товара', type: 'error' })
|
||||
}
|
||||
@@ -95,28 +73,14 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasValidPeriod) {
|
||||
setToastMessage({ text: 'Укажите период повторения', type: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
let repetitionPeriod = null
|
||||
if (repetitionPeriodValue && repetitionPeriodValue.trim() !== '') {
|
||||
const val = parseInt(repetitionPeriodValue.trim(), 10)
|
||||
if (!isNaN(val) && val > 0) {
|
||||
repetitionPeriod = `${val} ${repetitionPeriodType}`
|
||||
}
|
||||
}
|
||||
|
||||
const vb = volumeBase.trim() ? parseFloat(volumeBase.trim()) : null
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
group_name: groupName.trim() || null,
|
||||
volume_base: vb && vb > 0 ? vb : null,
|
||||
repetition_period: repetitionPeriod,
|
||||
}
|
||||
|
||||
let url, method
|
||||
@@ -224,8 +188,6 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
|
||||
window.history.back()
|
||||
}
|
||||
|
||||
const hasValidPeriod = repetitionPeriodValue && repetitionPeriodValue.trim() !== '' && parseInt(repetitionPeriodValue.trim(), 10) > 0
|
||||
|
||||
if (loadingItem) {
|
||||
return (
|
||||
<div className="shopping-item-form">
|
||||
@@ -292,7 +254,7 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="item-volume">Объём</label>
|
||||
<label htmlFor="item-volume">Шаги объёма</label>
|
||||
<input
|
||||
id="item-volume"
|
||||
type="number"
|
||||
@@ -305,36 +267,6 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="item-repetition">Повторения</label>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<span className="repetition-label">Хватает на</span>
|
||||
<input
|
||||
id="item-repetition"
|
||||
type="number"
|
||||
min="0"
|
||||
className="form-input"
|
||||
value={repetitionPeriodValue}
|
||||
onChange={e => setRepetitionPeriodValue(e.target.value)}
|
||||
placeholder="Число"
|
||||
style={{ flex: '1' }}
|
||||
/>
|
||||
<select
|
||||
value={repetitionPeriodType}
|
||||
onChange={e => setRepetitionPeriodType(e.target.value)}
|
||||
className="form-input"
|
||||
style={{ width: '120px' }}
|
||||
>
|
||||
<option value="minute">Минута</option>
|
||||
<option value="hour">Час</option>
|
||||
<option value="day">День</option>
|
||||
<option value="week">Неделя</option>
|
||||
<option value="month">Месяц</option>
|
||||
<option value="year">Год</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{toastMessage && (
|
||||
@@ -361,7 +293,7 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
|
||||
}}>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={loading || isDeleting || isCopying || !name.trim() || !hasValidPeriod}
|
||||
disabled={loading || isDeleting || isCopying || !name.trim()}
|
||||
style={{
|
||||
flex: 1,
|
||||
maxWidth: '42rem',
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
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 [records, setRecords] = 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 () => {
|
||||
const fetchRecords = useCallback(async () => {
|
||||
if (!itemId) return
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await authFetch(`/api/shopping/items/${itemId}/history`)
|
||||
const response = await authFetch(`/api/shopping/items/${itemId}/volume-records`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка загрузки истории')
|
||||
}
|
||||
const data = await response.json()
|
||||
setHistory(Array.isArray(data) ? data : [])
|
||||
setRecords(Array.isArray(data) ? data : [])
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@@ -31,26 +28,8 @@ function ShoppingItemHistory({ itemId, onNavigate }) {
|
||||
}, [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)
|
||||
}
|
||||
}
|
||||
fetchRecords()
|
||||
}, [fetchRecords])
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr)
|
||||
@@ -64,11 +43,29 @@ function ShoppingItemHistory({ itemId, onNavigate }) {
|
||||
}
|
||||
|
||||
const formatVolume = (volume) => {
|
||||
if (volume === 1) return '1'
|
||||
const rounded = Math.round(volume * 10000) / 10000
|
||||
if (volume == null) return '—'
|
||||
const rounded = Math.round(volume * 10) / 10
|
||||
return rounded.toString()
|
||||
}
|
||||
|
||||
const getActionLabel = (actionType) => {
|
||||
switch (actionType) {
|
||||
case 'purchase': return 'Покупка'
|
||||
case 'postpone': return 'Перенос'
|
||||
case 'create': return 'Создание'
|
||||
default: return actionType
|
||||
}
|
||||
}
|
||||
|
||||
const getActionColor = (actionType) => {
|
||||
switch (actionType) {
|
||||
case 'purchase': return '#059669'
|
||||
case 'postpone': return '#d97706'
|
||||
case 'create': return '#6b7280'
|
||||
default: return '#6b7280'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{onNavigate && (
|
||||
@@ -89,90 +86,70 @@ function ShoppingItemHistory({ itemId, onNavigate }) {
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<LoadingError onRetry={fetchHistory} />
|
||||
) : history.length === 0 ? (
|
||||
<LoadingError onRetry={fetchRecords} />
|
||||
) : records.length === 0 ? (
|
||||
<>
|
||||
<h2 className="text-2xl font-semibold text-gray-800 mb-6" style={{ marginTop: '1.25rem' }}>История покупок</h2>
|
||||
<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>
|
||||
<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="Удалить"
|
||||
{records.map((record) => {
|
||||
const total = (record.volume_remaining || 0) + (record.volume_purchased || 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="bg-white rounded-lg p-4 shadow-sm border border-gray-200"
|
||||
>
|
||||
{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>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ color: getActionColor(record.action_type), fontWeight: 600, fontSize: '0.9rem' }}>
|
||||
{getActionLabel(record.action_type)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatDate(record.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{record.action_type === 'purchase' && (
|
||||
<div className="text-gray-800 mt-2">
|
||||
{formatVolume(record.volume_remaining)} → {formatVolume(total)}
|
||||
</div>
|
||||
)}
|
||||
{record.action_type === 'postpone' && (
|
||||
<div className="text-gray-800 mt-2">
|
||||
{record.next_show_at ? (() => {
|
||||
const d = new Date(record.next_show_at)
|
||||
const day = d.getDate()
|
||||
const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
|
||||
return `на ${day} ${months[d.getMonth()]}`
|
||||
})() : 'Без даты'}
|
||||
{record.volume_remaining != null && (
|
||||
<span className="text-gray-500" style={{ marginLeft: '8px' }}>
|
||||
(остаток: {formatVolume(record.volume_remaining)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{record.action_type === 'create' && (
|
||||
<div className="text-gray-500 text-sm mt-1">
|
||||
Остаток: {formatVolume(record.volume_remaining)}
|
||||
</div>
|
||||
)}
|
||||
{record.daily_consumption != null && record.daily_consumption > 0 && (
|
||||
<div className="text-gray-500 text-xs mt-1">
|
||||
~{formatVolume(record.daily_consumption)}/день
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DayPicker } from 'react-day-picker'
|
||||
import { ru } from 'react-day-picker/locale'
|
||||
import 'react-day-picker/style.css'
|
||||
import './TaskList.css'
|
||||
import './TaskDetail.css'
|
||||
import './ShoppingList.css'
|
||||
|
||||
const BOARDS_CACHE_KEY = 'shopping_boards_cache'
|
||||
@@ -129,6 +130,7 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
|
||||
const [selectedItemForDetail, setSelectedItemForDetail] = useState(null)
|
||||
const [selectedItemForPostpone, setSelectedItemForPostpone] = useState(null)
|
||||
const [postponeDate, setPostponeDate] = useState('')
|
||||
const [postponeRemaining, setPostponeRemaining] = useState('')
|
||||
const [isPostponing, setIsPostponing] = useState(false)
|
||||
const [toast, setToast] = useState(null)
|
||||
const initialFetchDoneRef = useRef(false)
|
||||
@@ -319,6 +321,7 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
|
||||
if (currentPostpone) {
|
||||
setSelectedItemForPostpone(null)
|
||||
setPostponeDate('')
|
||||
setPostponeRemaining('')
|
||||
historyPushedForPostponeRef.current = false
|
||||
if (currentDetail) {
|
||||
window.history.pushState({ modalOpen: true, type: 'shopping-detail' }, '', window.location.href)
|
||||
@@ -420,28 +423,9 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
|
||||
|
||||
const openPostpone = (item) => {
|
||||
setSelectedItemForPostpone(item)
|
||||
// Предвыбираем дату "по плану" если она не совпадает с текущей next_show_at
|
||||
const now2 = new Date()
|
||||
now2.setHours(0, 0, 0, 0)
|
||||
let planned
|
||||
if (item.repetition_period) {
|
||||
planned = calculateNextDateFromRepetitionPeriod(item.repetition_period)
|
||||
}
|
||||
if (!planned) {
|
||||
planned = new Date(now2)
|
||||
planned.setDate(planned.getDate() + 1)
|
||||
}
|
||||
planned.setHours(0, 0, 0, 0)
|
||||
const plannedStr = formatDateToLocal(planned)
|
||||
let nextShowStr = null
|
||||
if (item.next_show_at) {
|
||||
nextShowStr = formatDateToLocal(new Date(item.next_show_at))
|
||||
}
|
||||
if (plannedStr !== nextShowStr) {
|
||||
setPostponeDate(plannedStr)
|
||||
} else {
|
||||
setPostponeDate('')
|
||||
}
|
||||
const remainingVal = item.estimated_remaining != null && item.estimated_remaining > 0 ? (Math.round(item.estimated_remaining * 10) / 10).toString() : ''
|
||||
setPostponeRemaining(remainingVal)
|
||||
// Дата рассчитывается в useEffect по остатку
|
||||
}
|
||||
|
||||
// Модалка переноса
|
||||
@@ -452,6 +436,7 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
|
||||
historyPushedForPostponeRef.current = false
|
||||
setSelectedItemForPostpone(null)
|
||||
setPostponeDate('')
|
||||
setPostponeRemaining('')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,10 +459,14 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
|
||||
setIsPostponing(true)
|
||||
try {
|
||||
const nextShowAt = new Date(dateStr + 'T00:00:00')
|
||||
const payload = { next_show_at: nextShowAt.toISOString() }
|
||||
if (postponeRemaining.trim()) {
|
||||
payload.volume_remaining = parseFloat(postponeRemaining)
|
||||
}
|
||||
const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ next_show_at: nextShowAt.toISOString() })
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (res.ok) {
|
||||
setToast({ message: 'Дата перенесена', type: 'success' })
|
||||
@@ -507,10 +496,14 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
|
||||
if (!selectedItemForPostpone) return
|
||||
setIsPostponing(true)
|
||||
try {
|
||||
const payload = { next_show_at: null }
|
||||
if (postponeRemaining.trim()) {
|
||||
payload.volume_remaining = parseFloat(postponeRemaining)
|
||||
}
|
||||
const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ next_show_at: null })
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (res.ok) {
|
||||
setToast({ message: 'Дата убрана', type: 'success' })
|
||||
@@ -525,6 +518,28 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
|
||||
}
|
||||
|
||||
|
||||
// Автообновление даты в календаре при вводе остатка
|
||||
useEffect(() => {
|
||||
if (!selectedItemForPostpone) return
|
||||
if (selectedItemForPostpone.daily_consumption > 0) {
|
||||
const remaining = postponeRemaining.trim() ? parseFloat(postponeRemaining) : (selectedItemForPostpone.estimated_remaining ?? 0)
|
||||
if (!isNaN(remaining) && remaining >= 0) {
|
||||
const daily = selectedItemForPostpone.daily_consumption
|
||||
const daysLeft = remaining / daily
|
||||
const target = new Date()
|
||||
target.setHours(0, 0, 0, 0)
|
||||
target.setDate(target.getDate() + Math.ceil(daysLeft))
|
||||
setPostponeDate(formatDateToLocal(target))
|
||||
return
|
||||
}
|
||||
}
|
||||
// Фолбэк: завтра
|
||||
const tomorrow = new Date()
|
||||
tomorrow.setHours(0, 0, 0, 0)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
setPostponeDate(formatDateToLocal(tomorrow))
|
||||
}, [postponeRemaining, selectedItemForPostpone])
|
||||
|
||||
const groupNames = useMemo(() => {
|
||||
const names = Object.keys(groupedItems)
|
||||
return names.sort((a, b) => {
|
||||
@@ -658,7 +673,14 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
|
||||
</div>
|
||||
<div className="task-name-container">
|
||||
<div className="task-name-wrapper">
|
||||
<div className="task-name">{item.name}</div>
|
||||
<div className="task-name">
|
||||
{item.name}
|
||||
{item.estimated_remaining > 0 && (
|
||||
<span style={{ color: '#9ca3af', fontSize: '0.8em', marginLeft: '6px' }}>
|
||||
~{Math.round(item.estimated_remaining * 10) / 10}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{dateDisplay && (
|
||||
<div className="task-next-show-date">{dateDisplay}</div>
|
||||
)}
|
||||
@@ -722,7 +744,14 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
|
||||
</div>
|
||||
<div className="task-name-container">
|
||||
<div className="task-name-wrapper">
|
||||
<div className="task-name">{item.name}</div>
|
||||
<div className="task-name">
|
||||
{item.name}
|
||||
{item.estimated_remaining > 0 && (
|
||||
<span style={{ color: '#9ca3af', fontSize: '0.8em', marginLeft: '6px' }}>
|
||||
~{Math.round(item.estimated_remaining * 10) / 10}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{dateDisplay && (
|
||||
<div className="task-next-show-date">{dateDisplay}</div>
|
||||
)}
|
||||
@@ -811,6 +840,41 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
|
||||
</button>
|
||||
</div>
|
||||
<div className="task-postpone-modal-content">
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<label className="progression-label" style={{ marginBottom: '0.25rem' }}>Остаток</label>
|
||||
<div className="progression-input-wrapper">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={postponeRemaining}
|
||||
onChange={(e) => setPostponeRemaining(e.target.value)}
|
||||
placeholder={selectedItemForPostpone.estimated_remaining != null ? (Math.round(selectedItemForPostpone.estimated_remaining * 10) / 10).toString() : '0'}
|
||||
className="progression-input"
|
||||
style={{ paddingRight: postponeRemaining ? '2rem' : '0.75rem' }}
|
||||
/>
|
||||
{postponeRemaining && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPostponeRemaining('')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '8px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#9ca3af',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.1rem',
|
||||
padding: '4px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-postpone-calendar">
|
||||
<DayPicker
|
||||
mode="single"
|
||||
@@ -844,13 +908,21 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
|
||||
Завтра
|
||||
</button>
|
||||
)}
|
||||
{!isCurrentDatePlanned && (
|
||||
{selectedItemForPostpone.daily_consumption > 0 && (
|
||||
<button
|
||||
onClick={() => handlePostponeSubmitWithDate(plannedDateStr)}
|
||||
onClick={() => {
|
||||
const remaining = postponeRemaining.trim() ? parseFloat(postponeRemaining) : (selectedItemForPostpone.estimated_remaining ?? 0)
|
||||
const daily = selectedItemForPostpone.daily_consumption
|
||||
const daysLeft = remaining / daily
|
||||
const target = new Date()
|
||||
target.setHours(0, 0, 0, 0)
|
||||
target.setDate(target.getDate() + Math.ceil(daysLeft))
|
||||
handlePostponeSubmitWithDate(formatDateToLocal(target))
|
||||
}}
|
||||
className="task-postpone-quick-button"
|
||||
disabled={isPostponing}
|
||||
>
|
||||
По плану
|
||||
По остатку
|
||||
</button>
|
||||
)}
|
||||
{selectedItemForPostpone?.next_show_at && (
|
||||
|
||||
@@ -351,8 +351,9 @@
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
width: fit-content;
|
||||
max-width: 90%;
|
||||
max-width: min(90%, 350px);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-postpone-modal-header {
|
||||
|
||||
Reference in New Issue
Block a user