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

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