6.22.0: Авторасчёт сроков товаров по истории
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:
poignatov
2026-03-19 11:37:39 +03:00
parent 664adcfaa5
commit f1c12fd81a
13 changed files with 716 additions and 323 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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